diff --git a/cmd/gitops.go b/cmd/gitops.go new file mode 100644 index 0000000..e1db44e --- /dev/null +++ b/cmd/gitops.go @@ -0,0 +1,347 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/VapiAI/cli/pkg/config" + "github.com/VapiAI/cli/pkg/gitops" +) + +var gitopsCmd = &cobra.Command{ + Use: "gitops", + Short: "Manage Vapi resources via GitOps", + Long: `Manage Vapi resources (Assistants, Tools, Structured Outputs) declaratively via YAML files. + +GitOps enables version control, code review, and team collaboration for your Vapi configuration. + +Commands: + init - Initialize a new GitOps project structure + pull - Pull resources from Vapi to local YAML files + apply - Push local YAML changes to Vapi + +Example workflow: + vapi gitops init # Create project structure + vapi gitops pull # Fetch existing resources + # Edit YAML files... + vapi gitops apply # Push changes to Vapi`, +} + +var gitopsInitCmd = &cobra.Command{ + Use: "init [path]", + Short: "Initialize a GitOps project structure", + Long: `Initialize a new GitOps project with the required directory structure. + +Creates: + resources/ + assistants/ - Assistant YAML files + tools/ - Tool YAML files + structuredOutputs/ - Structured Output YAML files + .vapi-state.json - State file mapping resource IDs to UUIDs + .gitignore - Updated with gitops entries + .env.example - Example environment file`, + Args: cobra.MaximumNArgs(1), + RunE: runGitopsInit, +} + +var gitopsPullCmd = &cobra.Command{ + Use: "pull", + Short: "Pull resources from Vapi to local YAML files", + Long: `Pull all resources from your Vapi account and save them as local YAML files. + +This will: +- Fetch all Assistants, Tools, and Structured Outputs from Vapi +- Save them as YAML files in the resources/ directory +- Update .vapi-state.json with UUID mappings +- Resolve cross-references to use resource IDs instead of UUIDs + +Existing files will be overwritten with the latest data from Vapi.`, + RunE: runGitopsPull, +} + +var gitopsApplyCmd = &cobra.Command{ + Use: "apply", + Short: "Apply local YAML changes to Vapi", + Long: `Apply local YAML resource files to your Vapi account. + +This will: +- Load all YAML files from the resources/ directory +- Create new resources that don't exist in Vapi +- Update existing resources with local changes +- Delete resources that were removed locally (with confirmation) +- Resolve cross-references (e.g., tool IDs in assistants) + +Resources are applied in dependency order: + 1. Tools + 2. Structured Outputs + 3. Assistants`, + RunE: runGitopsApply, +} + +func init() { + rootCmd.AddCommand(gitopsCmd) + gitopsCmd.AddCommand(gitopsInitCmd) + gitopsCmd.AddCommand(gitopsPullCmd) + gitopsCmd.AddCommand(gitopsApplyCmd) +} + +func runGitopsInit(cmd *cobra.Command, args []string) error { + projectPath := "." + if len(args) > 0 { + projectPath = args[0] + } + + // Make path absolute + absPath, err := filepath.Abs(projectPath) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + // Check if already initialized + if gitops.IsGitOpsProject(absPath) { + return fmt.Errorf("GitOps project already initialized at %s", absPath) + } + + fmt.Println("šŸš€ Initializing Vapi GitOps project...") + fmt.Println() + + // Create project structure + if err := gitops.InitProject(absPath); err != nil { + return err + } + + // Create .env.example + if err := gitops.CreateEnvExample(absPath); err != nil { + return err + } + + fmt.Println() + fmt.Println("āœ… GitOps project initialized!") + fmt.Println() + fmt.Println("šŸ“ Next steps:") + fmt.Println() + fmt.Println("1. Configure your API key:") + fmt.Println(" cp .env.example .env") + fmt.Println(" # Edit .env and add your VAPI_TOKEN") + fmt.Println() + fmt.Println("2. Pull existing resources from Vapi:") + fmt.Println(" vapi gitops pull") + fmt.Println() + fmt.Println("3. Or start creating resources in the YAML files") + fmt.Println(" and apply them:") + fmt.Println(" vapi gitops apply") + + return nil +} + +func runGitopsPull(cmd *cobra.Command, args []string) error { + projectPath, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + + // Check if GitOps project + if !gitops.IsGitOpsProject(projectPath) { + return fmt.Errorf("not a GitOps project (run 'vapi gitops init' first)") + } + + // Get API key + apiKey, err := getAPIKey() + if err != nil { + return err + } + + // Create config + cfg := gitops.NewConfig(projectPath) + cfg.APIKey = apiKey + + // Load CLI config to get base URL + cliConfig, err := config.LoadConfig() + if err == nil && cliConfig.GetAPIBaseURL() != "" { + cfg.APIBaseURL = cliConfig.GetAPIBaseURL() + } + + fmt.Println("═══════════════════════════════════════════════════════════════") + fmt.Println("šŸ”„ Vapi GitOps Pull") + fmt.Printf(" API: %s\n", cfg.APIBaseURL) + fmt.Println("═══════════════════════════════════════════════════════════════") + + // Create and run pull engine + engine, err := gitops.NewPullEngine(cfg) + if err != nil { + return err + } + + ctx := context.Background() + stats, err := engine.Pull(ctx) + if err != nil { + return err + } + + fmt.Println() + fmt.Println("═══════════════════════════════════════════════════════════════") + fmt.Println("āœ… Pull complete!") + fmt.Println("═══════════════════════════════════════════════════════════════") + fmt.Println() + fmt.Println("šŸ“‹ Summary:") + for rt, s := range stats { + fmt.Printf(" %s: %d new, %d existing\n", rt, s.Created, s.Updated) + } + + return nil +} + +func runGitopsApply(cmd *cobra.Command, args []string) error { + projectPath, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + + // Check if GitOps project + if !gitops.IsGitOpsProject(projectPath) { + return fmt.Errorf("not a GitOps project (run 'vapi gitops init' first)") + } + + // Get API key + apiKey, err := getAPIKey() + if err != nil { + return err + } + + // Create config + cfg := gitops.NewConfig(projectPath) + cfg.APIKey = apiKey + + // Load CLI config to get base URL + cliConfig, err := config.LoadConfig() + if err == nil && cliConfig.GetAPIBaseURL() != "" { + cfg.APIBaseURL = cliConfig.GetAPIBaseURL() + } + + fmt.Println("═══════════════════════════════════════════════════════════════") + fmt.Println("šŸš€ Vapi GitOps Apply") + fmt.Printf(" API: %s\n", cfg.APIBaseURL) + fmt.Println("═══════════════════════════════════════════════════════════════") + + // Create and run apply engine + engine, err := gitops.NewApplyEngine(cfg) + if err != nil { + return err + } + + ctx := context.Background() + if err := engine.Apply(ctx); err != nil { + return err + } + + fmt.Println() + fmt.Println("═══════════════════════════════════════════════════════════════") + fmt.Println("āœ… Apply complete!") + fmt.Println("═══════════════════════════════════════════════════════════════") + fmt.Println() + fmt.Printf("šŸ“‹ Summary: %s\n", engine.Summary()) + + return nil +} + +// getAPIKey retrieves the API key from environment or config. +func getAPIKey() (string, error) { + // Try environment variable first + if key := os.Getenv("VAPI_TOKEN"); key != "" { + return key, nil + } + + // Try loading from .env file + envFile := ".env" + if data, err := os.ReadFile(envFile); err == nil { + if key := parseEnvVar(string(data), "VAPI_TOKEN"); key != "" { + return key, nil + } + } + + // Try CLI config + cfg, err := config.LoadConfig() + if err == nil && cfg.APIKey != "" { + return cfg.APIKey, nil + } + + return "", fmt.Errorf("API key not found. Set VAPI_TOKEN in .env or run 'vapi login'") +} + +// parseEnvVar extracts a variable value from .env content. +func parseEnvVar(content, varName string) string { + lines := splitLines(content) + prefix := varName + "=" + + for _, line := range lines { + line = trimSpace(line) + if len(line) == 0 || line[0] == '#' { + continue + } + if hasPrefix(line, prefix) { + value := line[len(prefix):] + // Remove quotes if present + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'') { + value = value[1 : len(value)-1] + } + } + return value + } + } + return "" +} + +// splitLines splits content into lines. +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} + +// trimSpace removes leading and trailing whitespace. +func trimSpace(s string) string { + start := 0 + end := len(s) + for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\r') { + start++ + } + for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r') { + end-- + } + return s[start:end] +} + +// hasPrefix checks if s starts with prefix. +func hasPrefix(s, prefix string) bool { + return len(s) >= len(prefix) && s[:len(prefix)] == prefix +} diff --git a/pkg/gitops/api.go b/pkg/gitops/api.go new file mode 100644 index 0000000..11c92b0 --- /dev/null +++ b/pkg/gitops/api.go @@ -0,0 +1,181 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// APIClient handles HTTP requests to the Vapi API. +type APIClient struct { + baseURL string + apiKey string + httpClient *http.Client +} + +// NewAPIClient creates a new API client. +func NewAPIClient(baseURL, apiKey string) *APIClient { + return &APIClient{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Request sends an HTTP request to the Vapi API. +func (c *APIClient) Request(ctx context.Context, method, path string, body interface{}) (map[string]interface{}, error) { + url := c.baseURL + path + + var bodyReader io.Reader + if body != nil { + jsonData, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(jsonData) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) + } + + // Handle empty responses + if len(respBody) == 0 { + return nil, nil + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return result, nil +} + +// Get sends a GET request. +func (c *APIClient) Get(ctx context.Context, path string) (map[string]interface{}, error) { + return c.Request(ctx, http.MethodGet, path, nil) +} + +// Post sends a POST request. +func (c *APIClient) Post(ctx context.Context, path string, body interface{}) (map[string]interface{}, error) { + return c.Request(ctx, http.MethodPost, path, body) +} + +// Patch sends a PATCH request. +func (c *APIClient) Patch(ctx context.Context, path string, body interface{}) (map[string]interface{}, error) { + return c.Request(ctx, http.MethodPatch, path, body) +} + +// Delete sends a DELETE request. +func (c *APIClient) Delete(ctx context.Context, path string) error { + _, err := c.Request(ctx, http.MethodDelete, path, nil) + return err +} + +// ListResources fetches all resources of a given type. +func (c *APIClient) ListResources(ctx context.Context, rt ResourceType) ([]map[string]interface{}, error) { + endpoint := GetAPIEndpoint(rt) + url := c.baseURL + endpoint + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) + } + + var result []map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return result, nil +} + +// CreateResource creates a new resource. +func (c *APIClient) CreateResource(ctx context.Context, rt ResourceType, data map[string]interface{}) (map[string]interface{}, error) { + endpoint := GetAPIEndpoint(rt) + return c.Post(ctx, endpoint, data) +} + +// UpdateResource updates an existing resource. +func (c *APIClient) UpdateResource(ctx context.Context, rt ResourceType, uuid string, data map[string]interface{}) (map[string]interface{}, error) { + endpoint := GetAPIEndpoint(rt) + "/" + uuid + // Remove excluded keys for update + cleanData := RemoveExcludedKeys(data, rt) + return c.Patch(ctx, endpoint, cleanData) +} + +// DeleteResource deletes a resource. +func (c *APIClient) DeleteResource(ctx context.Context, rt ResourceType, uuid string) error { + endpoint := GetAPIEndpoint(rt) + "/" + uuid + return c.Delete(ctx, endpoint) +} + +// CleanResourceForPull removes server-managed fields from a resource. +func CleanResourceForPull(data map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + for k, v := range data { + if !IsExcludedOnPull(k) && v != nil { + result[k] = v + } + } + return result +} diff --git a/pkg/gitops/apply.go b/pkg/gitops/apply.go new file mode 100644 index 0000000..7a14ac2 --- /dev/null +++ b/pkg/gitops/apply.go @@ -0,0 +1,343 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +import ( + "context" + "fmt" +) + +// ApplyEngine handles the apply workflow. +type ApplyEngine struct { + config *Config + client *APIClient + state *StateFile +} + +// NewApplyEngine creates a new apply engine. +func NewApplyEngine(config *Config) (*ApplyEngine, error) { + if err := config.ValidateForApply(); err != nil { + return nil, err + } + + state, err := LoadState(config.StateFilePath) + if err != nil { + return nil, fmt.Errorf("failed to load state: %w", err) + } + + client := NewAPIClient(config.APIBaseURL, config.APIKey) + + return &ApplyEngine{ + config: config, + client: client, + state: state, + }, nil +} + +// Apply runs the full apply workflow. +func (e *ApplyEngine) Apply(ctx context.Context) error { + // Load all resources + loaded, err := LoadAllResources(e.config) + if err != nil { + return fmt.Errorf("failed to load resources: %w", err) + } + + // Delete orphaned resources first + if err := e.deleteOrphaned(ctx, loaded); err != nil { + return fmt.Errorf("failed to delete orphaned resources: %w", err) + } + + // Apply in dependency order: tools → structured outputs → assistants + fmt.Println("\nšŸ”§ Applying tools...") + for _, tool := range loaded.Tools { + if err := e.applyResource(ctx, tool); err != nil { + return fmt.Errorf("failed to apply tool %s: %w", tool.ResourceID, err) + } + } + + fmt.Println("\nšŸ“Š Applying structured outputs...") + for _, output := range loaded.StructuredOutputs { + if err := e.applyResource(ctx, output); err != nil { + return fmt.Errorf("failed to apply structured output %s: %w", output.ResourceID, err) + } + } + + fmt.Println("\nšŸ¤– Applying assistants...") + for _, assistant := range loaded.Assistants { + if err := e.applyResource(ctx, assistant); err != nil { + return fmt.Errorf("failed to apply assistant %s: %w", assistant.ResourceID, err) + } + } + + // Second pass: Link resources to assistants (now that assistants exist) + fmt.Println("\nšŸ”— Linking tools to assistant destinations...") + if err := e.updateToolAssistantRefs(ctx, loaded.Tools); err != nil { + return fmt.Errorf("failed to update tool assistant refs: %w", err) + } + + fmt.Println("\nšŸ”— Linking structured outputs to assistants...") + if err := e.updateStructuredOutputAssistantRefs(ctx, loaded.StructuredOutputs); err != nil { + return fmt.Errorf("failed to update structured output assistant refs: %w", err) + } + + // Save state + if err := SaveState(e.config.StateFilePath, e.state); err != nil { + return fmt.Errorf("failed to save state: %w", err) + } + + return nil +} + +// applyResource applies a single resource. +func (e *ApplyEngine) applyResource(ctx context.Context, resource *ResourceFile) error { + section := e.state.GetSection(resource.ResourceType) + existingUUID := section[resource.ResourceID] + + // Resolve references + payload := ResolveReferences(resource.Data, e.state) + + if existingUUID != "" { + // Update existing resource + fmt.Printf(" šŸ”„ Updating %s: %s (%s)\n", resource.ResourceType, resource.ResourceID, existingUUID) + _, err := e.client.UpdateResource(ctx, resource.ResourceType, existingUUID, payload) + if err != nil { + return err + } + } else { + // Create new resource + // For tools with assistant destinations, strip unresolved assistantIds + createPayload := payload + if resource.ResourceType == ResourceTypeTools { + createPayload = StripUnresolvedAssistantDestinations(payload, resource.Data) + } + + // For structured outputs, remove assistant_ids for initial creation + if resource.ResourceType == ResourceTypeStructuredOutputs { + delete(createPayload, "assistant_ids") + } + + fmt.Printf(" ✨ Creating %s: %s\n", resource.ResourceType, resource.ResourceID) + result, err := e.client.CreateResource(ctx, resource.ResourceType, createPayload) + if err != nil { + return err + } + + uuid, ok := result["id"].(string) + if !ok { + return fmt.Errorf("no ID returned from create") + } + + e.state.SetUUID(resource.ResourceType, resource.ResourceID, uuid) + } + + return nil +} + +// deleteOrphaned deletes resources that are in state but not in filesystem. +func (e *ApplyEngine) deleteOrphaned(ctx context.Context, loaded *LoadedResources) error { + fmt.Println("\nšŸ—‘ļø Checking for deleted resources...") + + // Find orphaned resources + orphaned := make(map[ResourceType][]OrphanedResource) + + for _, rt := range DeleteOrder() { + fileIDs := GetResourceIDsFromFiles(loaded.GetByType(rt)) + stateSection := e.state.GetSection(rt) + + for resourceID, uuid := range stateSection { + found := false + for _, fileID := range fileIDs { + if fileID == resourceID { + found = true + break + } + } + if !found { + orphaned[rt] = append(orphaned[rt], OrphanedResource{ + ResourceID: resourceID, + UUID: uuid, + }) + } + } + } + + // Check for orphan references before deleting + var errors []string + for rt, orphans := range orphaned { + for _, orphan := range orphans { + refs := findReferencingResources(orphan.ResourceID, rt, loaded) + if len(refs) > 0 { + errors = append(errors, fmt.Sprintf( + "Cannot delete %s \"%s\" - still referenced by: %v", + rt, orphan.ResourceID, refs, + )) + } + } + } + + if len(errors) > 0 { + fmt.Println("\nāŒ Orphan reference errors:") + for _, err := range errors { + fmt.Printf(" %s\n", err) + } + return fmt.Errorf("cannot delete resources that are still referenced") + } + + // Delete orphaned resources in reverse dependency order + for _, rt := range DeleteOrder() { + for _, orphan := range orphaned[rt] { + fmt.Printf(" šŸ—‘ļø Deleting %s: %s (%s)\n", rt, orphan.ResourceID, orphan.UUID) + if err := e.client.DeleteResource(ctx, rt, orphan.UUID); err != nil { + return fmt.Errorf("failed to delete %s %s: %w", rt, orphan.ResourceID, err) + } + e.state.DeleteResource(rt, orphan.ResourceID) + } + } + + return nil +} + +// findReferencingResources finds resources that reference a given resource ID. +func findReferencingResources(targetID string, targetType ResourceType, loaded *LoadedResources) []string { + var refs []string + + checkResource := func(resource *ResourceFile) { + extracted := ExtractReferencedIDs(resource.Data) + + switch targetType { + case ResourceTypeTools: + if ContainsReference(extracted.Tools, targetID) { + refs = append(refs, string(resource.ResourceType)+"/"+resource.ResourceID) + } + case ResourceTypeStructuredOutputs: + if ContainsReference(extracted.StructuredOutputs, targetID) { + refs = append(refs, string(resource.ResourceType)+"/"+resource.ResourceID) + } + case ResourceTypeAssistants: + if ContainsReference(extracted.Assistants, targetID) { + refs = append(refs, string(resource.ResourceType)+"/"+resource.ResourceID) + } + } + } + + for _, r := range loaded.Assistants { + checkResource(r) + } + for _, r := range loaded.StructuredOutputs { + checkResource(r) + } + for _, r := range loaded.Tools { + checkResource(r) + } + + return refs +} + +// updateToolAssistantRefs updates tools with assistant destination references. +func (e *ApplyEngine) updateToolAssistantRefs(ctx context.Context, tools []*ResourceFile) error { + for _, tool := range tools { + destinations, ok := tool.Data["destinations"].([]interface{}) + if !ok { + continue + } + + hasAssistantRefs := false + for _, dest := range destinations { + if destMap, ok := dest.(map[string]interface{}); ok { + if _, hasID := destMap["assistantId"].(string); hasID { + hasAssistantRefs = true + break + } + } + } + + if !hasAssistantRefs { + continue + } + + uuid := e.state.Tools[tool.ResourceID] + if uuid == "" { + continue + } + + // Resolve destinations now that all assistants exist + resolved := ResolveReferences(tool.Data, e.state) + + fmt.Printf(" šŸ”— Linking tool %s to assistant destinations\n", tool.ResourceID) + _, err := e.client.Patch(ctx, GetAPIEndpoint(ResourceTypeTools)+"/"+uuid, map[string]interface{}{ + "destinations": resolved["destinations"], + }) + if err != nil { + return fmt.Errorf("failed to update tool %s: %w", tool.ResourceID, err) + } + } + + return nil +} + +// updateStructuredOutputAssistantRefs updates structured outputs with assistant references. +func (e *ApplyEngine) updateStructuredOutputAssistantRefs(ctx context.Context, outputs []*ResourceFile) error { + for _, output := range outputs { + assistantIDs, ok := output.Data["assistant_ids"].([]interface{}) + if !ok || len(assistantIDs) == 0 { + continue + } + + uuid := e.state.StructuredOutputs[output.ResourceID] + if uuid == "" { + continue + } + + // Convert to string slice + ids := make([]string, 0, len(assistantIDs)) + for _, id := range assistantIDs { + if strID, ok := id.(string); ok { + ids = append(ids, strID) + } + } + + // Resolve assistant IDs + resolvedIDs := ResolveAssistantIDs(ids, e.state) + if len(resolvedIDs) == 0 { + continue + } + + fmt.Printf(" šŸ”— Linking structured output %s to assistants\n", output.ResourceID) + _, err := e.client.Patch(ctx, GetAPIEndpoint(ResourceTypeStructuredOutputs)+"/"+uuid, map[string]interface{}{ + "assistantIds": resolvedIDs, + }) + if err != nil { + return fmt.Errorf("failed to update structured output %s: %w", output.ResourceID, err) + } + } + + return nil +} + +// GetState returns the current state. +func (e *ApplyEngine) GetState() *StateFile { + return e.state +} + +// Summary returns a summary of the current state. +func (e *ApplyEngine) Summary() string { + return fmt.Sprintf( + "Tools: %d, Structured Outputs: %d, Assistants: %d", + len(e.state.Tools), + len(e.state.StructuredOutputs), + len(e.state.Assistants), + ) +} diff --git a/pkg/gitops/config.go b/pkg/gitops/config.go new file mode 100644 index 0000000..b0bbe43 --- /dev/null +++ b/pkg/gitops/config.go @@ -0,0 +1,166 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +import ( + "fmt" + "os" + "path/filepath" +) + +const ( + // DefaultResourcesDir is the default directory for resource YAML files. + DefaultResourcesDir = "resources" + + // DefaultStateFile is the default state file name. + DefaultStateFile = ".vapi-state.json" + + // DefaultAPIBaseURL is the default Vapi API base URL. + DefaultAPIBaseURL = "https://api.vapi.ai" +) + +// ResourceDirs maps resource types to their directory names. +var ResourceDirs = map[ResourceType]string{ + ResourceTypeAssistants: "assistants", + ResourceTypeTools: "tools", + ResourceTypeStructuredOutputs: "structuredOutputs", +} + +// APIEndpoints maps resource types to their API endpoints. +var APIEndpoints = map[ResourceType]string{ + ResourceTypeAssistants: "/assistant", + ResourceTypeTools: "/tool", + ResourceTypeStructuredOutputs: "/structured-output", +} + +// UpdateExcludedKeys lists fields that cannot be updated after creation. +var UpdateExcludedKeys = map[ResourceType][]string{ + ResourceTypeTools: {"type"}, + ResourceTypeAssistants: {}, + ResourceTypeStructuredOutputs: {"type"}, +} + +// ExcludedFieldsOnPull lists fields to remove when pulling resources. +var ExcludedFieldsOnPull = []string{ + "id", + "orgId", + "createdAt", + "updatedAt", + "analyticsMetadata", + "isDeleted", +} + +// Config holds the GitOps configuration. +type Config struct { + // ProjectPath is the root path of the GitOps project. + ProjectPath string + + // ResourcesDir is the directory containing resource YAML files. + ResourcesDir string + + // StateFilePath is the path to the state file. + StateFilePath string + + // APIBaseURL is the Vapi API base URL. + APIBaseURL string + + // APIKey is the Vapi API key. + APIKey string +} + +// NewConfig creates a new Config with default values. +func NewConfig(projectPath string) *Config { + return &Config{ + ProjectPath: projectPath, + ResourcesDir: filepath.Join(projectPath, DefaultResourcesDir), + StateFilePath: filepath.Join(projectPath, DefaultStateFile), + APIBaseURL: DefaultAPIBaseURL, + } +} + +// Validate checks if the configuration is valid. +func (c *Config) Validate() error { + if c.ProjectPath == "" { + return fmt.Errorf("project path is required") + } + + // Check if project path exists + if _, err := os.Stat(c.ProjectPath); os.IsNotExist(err) { + return fmt.Errorf("project path does not exist: %s", c.ProjectPath) + } + + return nil +} + +// ValidateForApply checks if the configuration is valid for apply operations. +func (c *Config) ValidateForApply() error { + if err := c.Validate(); err != nil { + return err + } + + if c.APIKey == "" { + return fmt.Errorf("API key is required") + } + + // Check if resources directory exists + if _, err := os.Stat(c.ResourcesDir); os.IsNotExist(err) { + return fmt.Errorf("resources directory does not exist: %s (run 'vapi gitops init' first)", c.ResourcesDir) + } + + return nil +} + +// GetResourceDir returns the full path to a resource type's directory. +func (c *Config) GetResourceDir(rt ResourceType) string { + return filepath.Join(c.ResourcesDir, ResourceDirs[rt]) +} + +// GetAPIEndpoint returns the API endpoint for a resource type. +func GetAPIEndpoint(rt ResourceType) string { + return APIEndpoints[rt] +} + +// IsExcludedOnUpdate checks if a field should be excluded on update. +func IsExcludedOnUpdate(rt ResourceType, field string) bool { + excluded := UpdateExcludedKeys[rt] + for _, f := range excluded { + if f == field { + return true + } + } + return false +} + +// IsExcludedOnPull checks if a field should be excluded when pulling. +func IsExcludedOnPull(field string) bool { + for _, f := range ExcludedFieldsOnPull { + if f == field { + return true + } + } + return false +} + +// RemoveExcludedKeys removes fields that cannot be updated. +func RemoveExcludedKeys(data map[string]interface{}, rt ResourceType) map[string]interface{} { + result := make(map[string]interface{}) + for k, v := range data { + if !IsExcludedOnUpdate(rt, k) { + result[k] = v + } + } + return result +} diff --git a/pkg/gitops/config_test.go b/pkg/gitops/config_test.go new file mode 100644 index 0000000..fcf580a --- /dev/null +++ b/pkg/gitops/config_test.go @@ -0,0 +1,170 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNewConfig(t *testing.T) { + cfg := NewConfig("/test/project") + + if cfg.ProjectPath != "/test/project" { + t.Errorf("ProjectPath = %s, expected /test/project", cfg.ProjectPath) + } + + expectedResources := filepath.Join("/test/project", DefaultResourcesDir) + if cfg.ResourcesDir != expectedResources { + t.Errorf("ResourcesDir = %s, expected %s", cfg.ResourcesDir, expectedResources) + } + + expectedState := filepath.Join("/test/project", DefaultStateFile) + if cfg.StateFilePath != expectedState { + t.Errorf("StateFilePath = %s, expected %s", cfg.StateFilePath, expectedState) + } + + if cfg.APIBaseURL != DefaultAPIBaseURL { + t.Errorf("APIBaseURL = %s, expected %s", cfg.APIBaseURL, DefaultAPIBaseURL) + } +} + +func TestConfig_GetResourceDir(t *testing.T) { + cfg := NewConfig("/test/project") + + tests := []struct { + rt ResourceType + expected string + }{ + {ResourceTypeAssistants, filepath.Join("/test/project", DefaultResourcesDir, "assistants")}, + {ResourceTypeTools, filepath.Join("/test/project", DefaultResourcesDir, "tools")}, + {ResourceTypeStructuredOutputs, filepath.Join("/test/project", DefaultResourcesDir, "structuredOutputs")}, + } + + for _, tt := range tests { + result := cfg.GetResourceDir(tt.rt) + if result != tt.expected { + t.Errorf("GetResourceDir(%s) = %s, expected %s", tt.rt, result, tt.expected) + } + } +} + +func TestConfig_ValidateForApply(t *testing.T) { + // Create temp directory for testing + tempDir, err := os.MkdirTemp("", "gitops-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + cfg := NewConfig(tempDir) + cfg.APIKey = "test-key" + + // Create resources directory + if err := os.MkdirAll(cfg.ResourcesDir, 0755); err != nil { + t.Fatalf("Failed to create resources dir: %v", err) + } + + // Should pass now + if err := cfg.ValidateForApply(); err != nil { + t.Errorf("ValidateForApply should pass with valid config, got: %v", err) + } + + // Test missing API key + cfg.APIKey = "" + if err := cfg.ValidateForApply(); err == nil { + t.Error("ValidateForApply should fail with empty API key") + } +} + +func TestGetAPIEndpoint(t *testing.T) { + tests := []struct { + rt ResourceType + expected string + }{ + {ResourceTypeAssistants, "/assistant"}, + {ResourceTypeTools, "/tool"}, + {ResourceTypeStructuredOutputs, "/structured-output"}, + } + + for _, tt := range tests { + result := GetAPIEndpoint(tt.rt) + if result != tt.expected { + t.Errorf("GetAPIEndpoint(%s) = %s, expected %s", tt.rt, result, tt.expected) + } + } +} + +func TestIsExcludedOnPull(t *testing.T) { + excludedKeys := []string{"id", "createdAt", "updatedAt", "orgId"} + for _, key := range excludedKeys { + if !IsExcludedOnPull(key) { + t.Errorf("IsExcludedOnPull(%s) should return true", key) + } + } + + allowedKeys := []string{"name", "model", "voice", "type"} + for _, key := range allowedKeys { + if IsExcludedOnPull(key) { + t.Errorf("IsExcludedOnPull(%s) should return false", key) + } + } +} + +func TestIsExcludedOnUpdate(t *testing.T) { + excludedKeys := []string{"id", "createdAt", "updatedAt", "orgId"} + for _, key := range excludedKeys { + if !IsExcludedOnUpdate(key) { + t.Errorf("IsExcludedOnUpdate(%s) should return true", key) + } + } + + allowedKeys := []string{"name", "model", "voice", "type"} + for _, key := range allowedKeys { + if IsExcludedOnUpdate(key) { + t.Errorf("IsExcludedOnUpdate(%s) should return false", key) + } + } +} + +func TestRemoveExcludedKeys(t *testing.T) { + data := map[string]interface{}{ + "id": "uuid-123", + "name": "Test Resource", + "createdAt": "2025-01-01", + "updatedAt": "2025-01-02", + "model": "gpt-4o", + } + + result := RemoveExcludedKeys(data, ResourceTypeAssistants) + + if _, exists := result["id"]; exists { + t.Error("id should be removed") + } + if _, exists := result["createdAt"]; exists { + t.Error("createdAt should be removed") + } + if _, exists := result["updatedAt"]; exists { + t.Error("updatedAt should be removed") + } + if result["name"] != "Test Resource" { + t.Error("name should be preserved") + } + if result["model"] != "gpt-4o" { + t.Error("model should be preserved") + } +} diff --git a/pkg/gitops/init.go b/pkg/gitops/init.go new file mode 100644 index 0000000..b96bd2c --- /dev/null +++ b/pkg/gitops/init.go @@ -0,0 +1,216 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +import ( + "fmt" + "os" + "path/filepath" +) + +// InitProject initializes a new GitOps project structure. +func InitProject(projectPath string) error { + config := NewConfig(projectPath) + + // Create resources directories + for _, rt := range AllResourceTypes() { + dir := config.GetResourceDir(rt) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create %s directory: %w", rt, err) + } + fmt.Printf(" āœ“ Created %s/\n", filepath.Join(DefaultResourcesDir, ResourceDirs[rt])) + + // Create .gitkeep to ensure empty directories are tracked + gitkeep := filepath.Join(dir, ".gitkeep") + if err := os.WriteFile(gitkeep, []byte{}, 0644); err != nil { + return fmt.Errorf("failed to create .gitkeep: %w", err) + } + } + + // Create empty state file + state := NewStateFile() + if err := SaveState(config.StateFilePath, state); err != nil { + return fmt.Errorf("failed to create state file: %w", err) + } + fmt.Printf(" āœ“ Created %s\n", DefaultStateFile) + + // Create or update .gitignore + if err := updateGitignore(projectPath); err != nil { + return fmt.Errorf("failed to update .gitignore: %w", err) + } + fmt.Println(" āœ“ Updated .gitignore") + + // Create example assistant + if err := createExampleFiles(config); err != nil { + return fmt.Errorf("failed to create example files: %w", err) + } + + return nil +} + +// updateGitignore adds gitops-related entries to .gitignore. +func updateGitignore(projectPath string) error { + gitignorePath := filepath.Join(projectPath, ".gitignore") + + entries := []string{ + "# Vapi GitOps", + ".env", + ".env.*", + "!.env.example", + } + + // Read existing content + existing := "" + if data, err := os.ReadFile(gitignorePath); err == nil { + existing = string(data) + } + + // Check if already contains gitops entries + if len(existing) > 0 && containsGitopsEntries(existing) { + return nil + } + + // Append entries + content := existing + if len(content) > 0 && content[len(content)-1] != '\n' { + content += "\n" + } + content += "\n" + for _, entry := range entries { + content += entry + "\n" + } + + return os.WriteFile(gitignorePath, []byte(content), 0644) +} + +// containsGitopsEntries checks if .gitignore already has gitops entries. +func containsGitopsEntries(content string) bool { + return len(content) > 0 && (contains(content, "# Vapi GitOps") || contains(content, ".env.*")) +} + +// contains checks if a string contains a substring. +func contains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// createExampleFiles creates example resource files. +func createExampleFiles(config *Config) error { + // Example tool + toolData := map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "get_weather", + "description": "Get the current weather for a location", + "parameters": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "location": map[string]interface{}{ + "type": "string", + "description": "The city name", + }, + }, + "required": []string{"location"}, + }, + }, + "server": map[string]interface{}{ + "url": "https://your-api.com/weather", + }, + } + + toolPath, err := WriteResourceFile(config, ResourceTypeTools, "example-get-weather", toolData) + if err != nil { + return err + } + fmt.Printf(" āœ“ Created example tool: %s\n", toolPath) + + // Example assistant + assistantData := map[string]interface{}{ + "name": "Example Assistant", + "model": map[string]interface{}{ + "provider": "openai", + "model": "gpt-4o", + "messages": []map[string]interface{}{ + { + "role": "system", + "content": "You are a helpful assistant. Be concise and friendly.", + }, + }, + "toolIds": []string{ + "example-get-weather", + }, + }, + "firstMessage": "Hello! How can I help you today?", + "voice": map[string]interface{}{ + "provider": "11labs", + "voiceId": "21m00Tcm4TlvDq8ikWAM", + }, + } + + assistantPath, err := WriteResourceFile(config, ResourceTypeAssistants, "example-assistant", assistantData) + if err != nil { + return err + } + fmt.Printf(" āœ“ Created example assistant: %s\n", assistantPath) + + return nil +} + +// CreateEnvExample creates an example .env file. +func CreateEnvExample(projectPath string) error { + envExamplePath := filepath.Join(projectPath, ".env.example") + + content := `# Vapi GitOps Configuration +# Copy this file to .env and fill in your values + +# Your Vapi API token (required) +# Get it from https://dashboard.vapi.ai/account +VAPI_TOKEN=your-token-here + +# API Base URL (optional, defaults to https://api.vapi.ai) +# VAPI_BASE_URL=https://api.vapi.ai +` + + if err := os.WriteFile(envExamplePath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to create .env.example: %w", err) + } + + fmt.Println(" āœ“ Created .env.example") + return nil +} + +// IsGitOpsProject checks if a directory is a GitOps project. +func IsGitOpsProject(projectPath string) bool { + config := NewConfig(projectPath) + + // Check for resources directory + if _, err := os.Stat(config.ResourcesDir); os.IsNotExist(err) { + return false + } + + // Check for at least one resource type directory + for _, rt := range AllResourceTypes() { + if _, err := os.Stat(config.GetResourceDir(rt)); err == nil { + return true + } + } + + return false +} diff --git a/pkg/gitops/init_test.go b/pkg/gitops/init_test.go new file mode 100644 index 0000000..9c33534 --- /dev/null +++ b/pkg/gitops/init_test.go @@ -0,0 +1,239 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +import ( + "os" + "path/filepath" + "testing" +) + +func TestInitProject(t *testing.T) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "gitops-init-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Initialize project + if err := InitProject(tempDir); err != nil { + t.Fatalf("InitProject failed: %v", err) + } + + // Verify resources directories exist + cfg := NewConfig(tempDir) + for _, rt := range AllResourceTypes() { + dir := cfg.GetResourceDir(rt) + if _, err := os.Stat(dir); os.IsNotExist(err) { + t.Errorf("Resource directory not created: %s", dir) + } + + // Verify .gitkeep exists + gitkeep := filepath.Join(dir, ".gitkeep") + if _, err := os.Stat(gitkeep); os.IsNotExist(err) { + t.Errorf(".gitkeep not created in: %s", dir) + } + } + + // Verify state file exists + if _, err := os.Stat(cfg.StateFilePath); os.IsNotExist(err) { + t.Error("State file not created") + } + + // Verify .gitignore exists and has content + gitignore := filepath.Join(tempDir, ".gitignore") + if _, err := os.Stat(gitignore); os.IsNotExist(err) { + t.Error(".gitignore not created") + } + + content, err := os.ReadFile(gitignore) + if err != nil { + t.Fatalf("Failed to read .gitignore: %v", err) + } + + gitignoreContent := string(content) + if !containsStr(gitignoreContent, ".env") { + t.Error(".gitignore should contain .env") + } + if !containsStr(gitignoreContent, "!.env.example") { + t.Error(".gitignore should contain !.env.example") + } + + // Verify example files were created + exampleTool := filepath.Join(cfg.GetResourceDir(ResourceTypeTools), "example-get-weather.yaml") + if _, err := os.Stat(exampleTool); os.IsNotExist(err) { + t.Error("Example tool file not created") + } + + exampleAssistant := filepath.Join(cfg.GetResourceDir(ResourceTypeAssistants), "example-assistant.yaml") + if _, err := os.Stat(exampleAssistant); os.IsNotExist(err) { + t.Error("Example assistant file not created") + } +} + +func TestInitProject_AlreadyInitialized(t *testing.T) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "gitops-init-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // First init should succeed + if err := InitProject(tempDir); err != nil { + t.Fatalf("First InitProject failed: %v", err) + } + + // Second init should also succeed (idempotent) + // It will overwrite existing files + if err := InitProject(tempDir); err != nil { + t.Fatalf("Second InitProject failed: %v", err) + } +} + +func TestIsGitOpsProject(t *testing.T) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "gitops-check-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Not a gitops project initially + if IsGitOpsProject(tempDir) { + t.Error("Should not be a GitOps project initially") + } + + // Initialize project + if err := InitProject(tempDir); err != nil { + t.Fatalf("InitProject failed: %v", err) + } + + // Should be a gitops project now + if !IsGitOpsProject(tempDir) { + t.Error("Should be a GitOps project after init") + } +} + +func TestCreateEnvExample(t *testing.T) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "gitops-env-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create .env.example + if err := CreateEnvExample(tempDir); err != nil { + t.Fatalf("CreateEnvExample failed: %v", err) + } + + // Verify file exists + envExample := filepath.Join(tempDir, ".env.example") + if _, err := os.Stat(envExample); os.IsNotExist(err) { + t.Error(".env.example not created") + } + + // Verify content + content, err := os.ReadFile(envExample) + if err != nil { + t.Fatalf("Failed to read .env.example: %v", err) + } + + contentStr := string(content) + if !containsStr(contentStr, "VAPI_TOKEN") { + t.Error(".env.example should contain VAPI_TOKEN") + } + if !containsStr(contentStr, "VAPI_BASE_URL") { + t.Error(".env.example should contain VAPI_BASE_URL") + } +} + +func TestUpdateGitignore_Existing(t *testing.T) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "gitops-gitignore-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + gitignorePath := filepath.Join(tempDir, ".gitignore") + + // Create existing .gitignore + existingContent := "node_modules/\n*.log\n" + if err := os.WriteFile(gitignorePath, []byte(existingContent), 0644); err != nil { + t.Fatalf("Failed to write initial .gitignore: %v", err) + } + + // Initialize project (which updates .gitignore) + if err := InitProject(tempDir); err != nil { + t.Fatalf("InitProject failed: %v", err) + } + + // Verify content + content, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("Failed to read .gitignore: %v", err) + } + + contentStr := string(content) + + // Should contain original content + if !containsStr(contentStr, "node_modules/") { + t.Error(".gitignore should preserve original content") + } + + // Should contain gitops entries + if !containsStr(contentStr, ".env") { + t.Error(".gitignore should contain .env") + } +} + +func TestUpdateGitignore_AlreadyHasEntries(t *testing.T) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "gitops-gitignore-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + gitignorePath := filepath.Join(tempDir, ".gitignore") + + // Create .gitignore with gitops entries already + existingContent := "# Vapi GitOps\n.env\n.env.*\n!.env.example\n" + if err := os.WriteFile(gitignorePath, []byte(existingContent), 0644); err != nil { + t.Fatalf("Failed to write initial .gitignore: %v", err) + } + + originalLen := len(existingContent) + + // Initialize project (which updates .gitignore) + if err := InitProject(tempDir); err != nil { + t.Fatalf("InitProject failed: %v", err) + } + + // Verify content wasn't duplicated + content, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("Failed to read .gitignore: %v", err) + } + + // File should not have grown significantly (entries should not be duplicated) + if len(content) > originalLen*2 { + t.Error(".gitignore entries should not be duplicated") + } +} diff --git a/pkg/gitops/pull.go b/pkg/gitops/pull.go new file mode 100644 index 0000000..cb2836e --- /dev/null +++ b/pkg/gitops/pull.go @@ -0,0 +1,169 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +import ( + "context" + "fmt" +) + +// PullEngine handles the pull workflow. +type PullEngine struct { + config *Config + client *APIClient + state *StateFile +} + +// NewPullEngine creates a new pull engine. +func NewPullEngine(config *Config) (*PullEngine, error) { + if err := config.ValidateForApply(); err != nil { + return nil, err + } + + state, err := LoadState(config.StateFilePath) + if err != nil { + return nil, fmt.Errorf("failed to load state: %w", err) + } + + client := NewAPIClient(config.APIBaseURL, config.APIKey) + + return &PullEngine{ + config: config, + client: client, + state: state, + }, nil +} + +// Pull runs the full pull workflow. +func (e *PullEngine) Pull(ctx context.Context) (map[ResourceType]*PullStats, error) { + stats := make(map[ResourceType]*PullStats) + + // Pull in dependency order (tools first, then structured outputs, then assistants) + // This ensures references can be resolved correctly + + fmt.Println("\nšŸ“„ Pulling tools...") + toolStats, err := e.pullResourceType(ctx, ResourceTypeTools) + if err != nil { + return nil, fmt.Errorf("failed to pull tools: %w", err) + } + stats[ResourceTypeTools] = toolStats + + fmt.Println("\nšŸ“„ Pulling structured outputs...") + outputStats, err := e.pullResourceType(ctx, ResourceTypeStructuredOutputs) + if err != nil { + return nil, fmt.Errorf("failed to pull structured outputs: %w", err) + } + stats[ResourceTypeStructuredOutputs] = outputStats + + fmt.Println("\nšŸ“„ Pulling assistants...") + assistantStats, err := e.pullResourceType(ctx, ResourceTypeAssistants) + if err != nil { + return nil, fmt.Errorf("failed to pull assistants: %w", err) + } + stats[ResourceTypeAssistants] = assistantStats + + // Save state + if err := SaveState(e.config.StateFilePath, e.state); err != nil { + return nil, fmt.Errorf("failed to save state: %w", err) + } + + return stats, nil +} + +// pullResourceType pulls all resources of a given type. +func (e *PullEngine) pullResourceType(ctx context.Context, rt ResourceType) (*PullStats, error) { + stats := &PullStats{} + + // Fetch all resources from API + resources, err := e.client.ListResources(ctx, rt) + if err != nil { + return nil, fmt.Errorf("failed to fetch resources: %w", err) + } + + fmt.Printf(" Found %d %s in Vapi\n", len(resources), rt) + + // Build reverse map for existing state + reverseMap := BuildReverseMap(e.state, rt) + existingIDs := make(map[string]bool) + for id := range e.state.GetSection(rt) { + existingIDs[id] = true + } + + // Track new state section + newSection := make(map[string]string) + + for _, resource := range resources { + uuid, ok := resource["id"].(string) + if !ok { + continue + } + + // Check if we already have this resource in state (by UUID) + resourceID := reverseMap[uuid] + isNew := resourceID == "" + + if isNew { + // Generate new resource ID from name or UUID + name, _ := resource["name"].(string) + if name == "" { + name = uuid[:8] + } + resourceID = GenerateUniqueResourceID(name, existingIDs) + existingIDs[resourceID] = true + stats.Created++ + } else { + stats.Updated++ + } + + // Clean resource (remove server-managed fields) + cleaned := CleanResourceForPull(resource) + + // Reverse resolve references (UUIDs → resource IDs) + resolved := ReverseResolveReferences(cleaned, e.state) + + // Write to file + filePath, err := WriteResourceFile(e.config, rt, resourceID, resolved) + if err != nil { + return nil, fmt.Errorf("failed to write %s: %w", resourceID, err) + } + + icon := "šŸ“" + if isNew { + icon = "✨" + } + fmt.Printf(" %s %s -> %s\n", icon, resourceID, filePath) + + // Update state + newSection[resourceID] = uuid + } + + // Update state with new mappings + switch rt { + case ResourceTypeTools: + e.state.Tools = newSection + case ResourceTypeStructuredOutputs: + e.state.StructuredOutputs = newSection + case ResourceTypeAssistants: + e.state.Assistants = newSection + } + + return stats, nil +} + +// GetState returns the current state. +func (e *PullEngine) GetState() *StateFile { + return e.state +} diff --git a/pkg/gitops/resolver.go b/pkg/gitops/resolver.go new file mode 100644 index 0000000..5db5765 --- /dev/null +++ b/pkg/gitops/resolver.go @@ -0,0 +1,354 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +import ( + "fmt" + "strings" +) + +// ResolveReferences resolves resource ID references to UUIDs in a resource's data. +// This handles toolIds, structuredOutputIds, assistant_ids, and assistantId in destinations. +func ResolveReferences(data map[string]interface{}, state *StateFile) map[string]interface{} { + result := deepCopyMap(data) + + // Resolve toolIds in model + if model, ok := result["model"].(map[string]interface{}); ok { + if toolIds, ok := model["toolIds"].([]interface{}); ok { + model["toolIds"] = resolveIDList(toolIds, state.Tools) + } + } + + // Resolve structuredOutputIds in artifactPlan + if artifactPlan, ok := result["artifactPlan"].(map[string]interface{}); ok { + if outputIds, ok := artifactPlan["structuredOutputIds"].([]interface{}); ok { + artifactPlan["structuredOutputIds"] = resolveIDList(outputIds, state.StructuredOutputs) + } + } + + // Resolve assistant_ids in structured outputs + if assistantIds, ok := result["assistant_ids"].([]interface{}); ok { + result["assistant_ids"] = resolveIDList(assistantIds, state.Assistants) + } + + // Resolve assistantId in destinations (handoff tools) + if destinations, ok := result["destinations"].([]interface{}); ok { + result["destinations"] = resolveDestinations(destinations, state.Assistants) + } + + return result +} + +// resolveIDList resolves a list of resource IDs to UUIDs. +func resolveIDList(ids []interface{}, stateMap map[string]string) []interface{} { + resolved := make([]interface{}, len(ids)) + for i, id := range ids { + if strID, ok := id.(string); ok { + resolved[i] = resolveID(strID, stateMap) + } else { + resolved[i] = id + } + } + return resolved +} + +// resolveID resolves a single resource ID to UUID. +// Strips inline comments (## ...) before resolving. +func resolveID(id string, stateMap map[string]string) string { + // Strip inline comments + id = stripComment(id) + + // Look up in state map + if uuid, ok := stateMap[id]; ok { + return uuid + } + + // Return original if not found (might be an actual UUID) + return id +} + +// resolveDestinations resolves assistantId in destination objects. +func resolveDestinations(destinations []interface{}, assistantMap map[string]string) []interface{} { + resolved := make([]interface{}, len(destinations)) + for i, dest := range destinations { + if destMap, ok := dest.(map[string]interface{}); ok { + resolvedDest := deepCopyMap(destMap) + if assistantID, ok := resolvedDest["assistantId"].(string); ok { + resolvedDest["assistantId"] = resolveID(assistantID, assistantMap) + } + resolved[i] = resolvedDest + } else { + resolved[i] = dest + } + } + return resolved +} + +// stripComment removes inline comments (## ...) from a reference. +func stripComment(ref string) string { + if idx := strings.Index(ref, "##"); idx != -1 { + return strings.TrimSpace(ref[:idx]) + } + return strings.TrimSpace(ref) +} + +// ResolveAssistantIDs resolves a list of assistant IDs to UUIDs. +func ResolveAssistantIDs(ids []string, state *StateFile) []string { + resolved := make([]string, 0, len(ids)) + for _, id := range ids { + cleanID := stripComment(id) + if uuid, ok := state.Assistants[cleanID]; ok { + resolved = append(resolved, uuid) + } + } + return resolved +} + +// ExtractReferencedIDs extracts all referenced resource IDs from a resource's data. +// Used for orphan detection. +func ExtractReferencedIDs(data map[string]interface{}) *ReferencedIDs { + refs := &ReferencedIDs{ + Tools: []string{}, + StructuredOutputs: []string{}, + Assistants: []string{}, + } + + // Extract toolIds from model + if model, ok := data["model"].(map[string]interface{}); ok { + if toolIds, ok := model["toolIds"].([]interface{}); ok { + for _, id := range toolIds { + if strID, ok := id.(string); ok { + refs.Tools = append(refs.Tools, stripComment(strID)) + } + } + } + } + + // Extract structuredOutputIds from artifactPlan + if artifactPlan, ok := data["artifactPlan"].(map[string]interface{}); ok { + if outputIds, ok := artifactPlan["structuredOutputIds"].([]interface{}); ok { + for _, id := range outputIds { + if strID, ok := id.(string); ok { + refs.StructuredOutputs = append(refs.StructuredOutputs, stripComment(strID)) + } + } + } + } + + // Extract assistant_ids + if assistantIds, ok := data["assistant_ids"].([]interface{}); ok { + for _, id := range assistantIds { + if strID, ok := id.(string); ok { + refs.Assistants = append(refs.Assistants, stripComment(strID)) + } + } + } + + // Extract assistantId from destinations + if destinations, ok := data["destinations"].([]interface{}); ok { + for _, dest := range destinations { + if destMap, ok := dest.(map[string]interface{}); ok { + if assistantID, ok := destMap["assistantId"].(string); ok { + refs.Assistants = append(refs.Assistants, stripComment(assistantID)) + } + } + } + } + + return refs +} + +// ReferencedIDs holds lists of referenced resource IDs. +type ReferencedIDs struct { + Tools []string + StructuredOutputs []string + Assistants []string +} + +// ReverseResolveReferences resolves UUIDs back to resource IDs (for pull). +func ReverseResolveReferences(data map[string]interface{}, state *StateFile) map[string]interface{} { + result := deepCopyMap(data) + + // Build reverse maps + toolsMap := BuildReverseMap(state, ResourceTypeTools) + assistantsMap := BuildReverseMap(state, ResourceTypeAssistants) + outputsMap := BuildReverseMap(state, ResourceTypeStructuredOutputs) + + // Reverse resolve toolIds in model + if model, ok := result["model"].(map[string]interface{}); ok { + if toolIds, ok := model["toolIds"].([]interface{}); ok { + model["toolIds"] = reverseResolveList(toolIds, toolsMap) + } + } + + // Reverse resolve structuredOutputIds in artifactPlan + if artifactPlan, ok := result["artifactPlan"].(map[string]interface{}); ok { + if outputIds, ok := artifactPlan["structuredOutputIds"].([]interface{}); ok { + artifactPlan["structuredOutputIds"] = reverseResolveList(outputIds, outputsMap) + } + } + + // Reverse resolve assistant_ids + if assistantIds, ok := result["assistant_ids"].([]interface{}); ok { + result["assistant_ids"] = reverseResolveList(assistantIds, assistantsMap) + } + + // Reverse resolve assistantId in destinations + if destinations, ok := result["destinations"].([]interface{}); ok { + result["destinations"] = reverseResolveDestinations(destinations, assistantsMap) + } + + return result +} + +// reverseResolveList resolves UUIDs back to resource IDs. +func reverseResolveList(ids []interface{}, reverseMap map[string]string) []interface{} { + resolved := make([]interface{}, len(ids)) + for i, id := range ids { + if uuid, ok := id.(string); ok { + if resourceID, ok := reverseMap[uuid]; ok { + resolved[i] = resourceID + } else { + resolved[i] = uuid + } + } else { + resolved[i] = id + } + } + return resolved +} + +// reverseResolveDestinations resolves UUIDs in destinations back to resource IDs. +func reverseResolveDestinations(destinations []interface{}, assistantMap map[string]string) []interface{} { + resolved := make([]interface{}, len(destinations)) + for i, dest := range destinations { + if destMap, ok := dest.(map[string]interface{}); ok { + resolvedDest := deepCopyMap(destMap) + if uuid, ok := resolvedDest["assistantId"].(string); ok { + if resourceID, ok := assistantMap[uuid]; ok { + resolvedDest["assistantId"] = resourceID + } + } + resolved[i] = resolvedDest + } else { + resolved[i] = dest + } + } + return resolved +} + +// deepCopyMap creates a deep copy of a map. +func deepCopyMap(m map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + for k, v := range m { + switch val := v.(type) { + case map[string]interface{}: + result[k] = deepCopyMap(val) + case []interface{}: + result[k] = deepCopySlice(val) + default: + result[k] = v + } + } + return result +} + +// deepCopySlice creates a deep copy of a slice. +func deepCopySlice(s []interface{}) []interface{} { + result := make([]interface{}, len(s)) + for i, v := range s { + switch val := v.(type) { + case map[string]interface{}: + result[i] = deepCopyMap(val) + case []interface{}: + result[i] = deepCopySlice(val) + default: + result[i] = v + } + } + return result +} + +// StripUnresolvedAssistantDestinations removes destinations with unresolved assistant IDs. +// Used during initial tool creation when assistants don't exist yet. +func StripUnresolvedAssistantDestinations(resolved, original map[string]interface{}) map[string]interface{} { + destResolved, okResolved := resolved["destinations"].([]interface{}) + destOriginal, okOriginal := original["destinations"].([]interface{}) + + if !okResolved || !okOriginal { + return resolved + } + + filtered := make([]interface{}, 0) + for i, dest := range destResolved { + if i >= len(destOriginal) { + continue + } + + destMap, ok := dest.(map[string]interface{}) + if !ok { + filtered = append(filtered, dest) + continue + } + + origMap, ok := destOriginal[i].(map[string]interface{}) + if !ok { + filtered = append(filtered, dest) + continue + } + + resolvedID, hasResolved := destMap["assistantId"].(string) + originalID, hasOriginal := origMap["assistantId"].(string) + + // Keep if no assistantId or if it was resolved (different from original) + if !hasResolved || !hasOriginal { + filtered = append(filtered, dest) + continue + } + + cleanOriginal := stripComment(originalID) + if resolvedID != cleanOriginal { + // Was resolved, keep it + filtered = append(filtered, dest) + } + // Otherwise, skip this destination (unresolved) + } + + result := deepCopyMap(resolved) + result["destinations"] = filtered + return result +} + +// ContainsReference checks if a reference ID is in a list. +func ContainsReference(refs []string, target string) bool { + for _, ref := range refs { + if ref == target { + return true + } + } + return false +} + +// ValidationError represents a reference validation error. +type ValidationError struct { + ResourceID string + ResourceType ResourceType + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("%s/%s: %s", e.ResourceType, e.ResourceID, e.Message) +} diff --git a/pkg/gitops/resolver_test.go b/pkg/gitops/resolver_test.go new file mode 100644 index 0000000..1d3e3a5 --- /dev/null +++ b/pkg/gitops/resolver_test.go @@ -0,0 +1,299 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +import ( + "testing" +) + +func TestStripInlineComment(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"my-tool ## This is a comment", "my-tool"}, + {"my-tool##comment", "my-tool"}, + {"my-tool", "my-tool"}, + {" my-tool ## comment ", "my-tool"}, + {"", ""}, + } + + for _, tt := range tests { + result := StripInlineComment(tt.input) + if result != tt.expected { + t.Errorf("StripInlineComment(%q) = %q, expected %q", tt.input, result, tt.expected) + } + } +} + +func TestBuildReverseMap(t *testing.T) { + state := NewStateFile() + state.Tools["my-tool"] = "uuid-tool-1" + state.Tools["other-tool"] = "uuid-tool-2" + state.Assistants["my-assistant"] = "uuid-assistant-1" + + // Test tools reverse map + toolsMap := BuildReverseMap(state, ResourceTypeTools) + if toolsMap["uuid-tool-1"] != "my-tool" { + t.Errorf("Expected uuid-tool-1 -> my-tool, got %s", toolsMap["uuid-tool-1"]) + } + if toolsMap["uuid-tool-2"] != "other-tool" { + t.Errorf("Expected uuid-tool-2 -> other-tool, got %s", toolsMap["uuid-tool-2"]) + } + + // Test assistants reverse map + assistantsMap := BuildReverseMap(state, ResourceTypeAssistants) + if assistantsMap["uuid-assistant-1"] != "my-assistant" { + t.Errorf("Expected uuid-assistant-1 -> my-assistant, got %s", assistantsMap["uuid-assistant-1"]) + } +} + +func TestResolveToolIDs(t *testing.T) { + state := NewStateFile() + state.Tools["weather-tool"] = "uuid-weather" + state.Tools["calendar-tool"] = "uuid-calendar" + + toolIDs := []string{ + "weather-tool ## Get weather", + "calendar-tool", + "unknown-tool", + } + + resolved := ResolveToolIDs(toolIDs, state) + + if len(resolved) != 3 { + t.Errorf("Expected 3 resolved IDs, got %d", len(resolved)) + } + + if resolved[0] != "uuid-weather" { + t.Errorf("Expected uuid-weather, got %s", resolved[0]) + } + if resolved[1] != "uuid-calendar" { + t.Errorf("Expected uuid-calendar, got %s", resolved[1]) + } + // Unknown tools should be kept as-is (might be a real UUID) + if resolved[2] != "unknown-tool" { + t.Errorf("Expected unknown-tool (unchanged), got %s", resolved[2]) + } +} + +func TestResolveAssistantIDs(t *testing.T) { + state := NewStateFile() + state.Assistants["support-bot"] = "uuid-support" + state.Assistants["sales-bot"] = "uuid-sales" + + assistantIDs := []string{ + "support-bot", + "sales-bot ## Sales assistant", + } + + resolved := ResolveAssistantIDs(assistantIDs, state) + + if len(resolved) != 2 { + t.Errorf("Expected 2 resolved IDs, got %d", len(resolved)) + } + + if resolved[0] != "uuid-support" { + t.Errorf("Expected uuid-support, got %s", resolved[0]) + } + if resolved[1] != "uuid-sales" { + t.Errorf("Expected uuid-sales, got %s", resolved[1]) + } +} + +func TestResolveReferences(t *testing.T) { + state := NewStateFile() + state.Tools["my-tool"] = "uuid-tool-123" + state.StructuredOutputs["my-output"] = "uuid-output-456" + + data := map[string]interface{}{ + "name": "Test Assistant", + "model": map[string]interface{}{ + "provider": "openai", + "model": "gpt-4o", + "toolIds": []interface{}{ + "my-tool ## Weather tool", + }, + }, + "structuredOutputIds": []interface{}{ + "my-output", + }, + } + + resolved := ResolveReferences(data, state) + + // Check that original data is not modified + originalToolIds := data["model"].(map[string]interface{})["toolIds"].([]interface{}) + if originalToolIds[0] != "my-tool ## Weather tool" { + t.Error("Original data should not be modified") + } + + // Check resolved values + model := resolved["model"].(map[string]interface{}) + toolIds := model["toolIds"].([]interface{}) + if toolIds[0] != "uuid-tool-123" { + t.Errorf("Expected uuid-tool-123, got %v", toolIds[0]) + } + + outputIds := resolved["structuredOutputIds"].([]interface{}) + if outputIds[0] != "uuid-output-456" { + t.Errorf("Expected uuid-output-456, got %v", outputIds[0]) + } +} + +func TestReverseResolveReferences(t *testing.T) { + state := NewStateFile() + state.Tools["my-tool"] = "uuid-tool-123" + state.Assistants["my-assistant"] = "uuid-assistant-789" + + data := map[string]interface{}{ + "name": "Test Tool", + "destinations": []interface{}{ + map[string]interface{}{ + "type": "assistant", + "assistantId": "uuid-assistant-789", + }, + }, + } + + resolved := ReverseResolveReferences(data, state) + + destinations := resolved["destinations"].([]interface{}) + dest := destinations[0].(map[string]interface{}) + + if dest["assistantId"] != "my-assistant" { + t.Errorf("Expected my-assistant, got %v", dest["assistantId"]) + } +} + +func TestExtractReferencedIDs(t *testing.T) { + data := map[string]interface{}{ + "model": map[string]interface{}{ + "toolIds": []interface{}{ + "tool-1", + "tool-2 ## comment", + }, + }, + "structuredOutputIds": []interface{}{ + "output-1", + }, + "destinations": []interface{}{ + map[string]interface{}{ + "assistantId": "assistant-1", + }, + }, + } + + refs := ExtractReferencedIDs(data) + + if len(refs.Tools) != 2 { + t.Errorf("Expected 2 tool refs, got %d", len(refs.Tools)) + } + if refs.Tools[0] != "tool-1" || refs.Tools[1] != "tool-2" { + t.Errorf("Unexpected tool refs: %v", refs.Tools) + } + + if len(refs.StructuredOutputs) != 1 { + t.Errorf("Expected 1 output ref, got %d", len(refs.StructuredOutputs)) + } + if refs.StructuredOutputs[0] != "output-1" { + t.Errorf("Unexpected output ref: %s", refs.StructuredOutputs[0]) + } + + if len(refs.Assistants) != 1 { + t.Errorf("Expected 1 assistant ref, got %d", len(refs.Assistants)) + } + if refs.Assistants[0] != "assistant-1" { + t.Errorf("Unexpected assistant ref: %s", refs.Assistants[0]) + } +} + +func TestContainsReference(t *testing.T) { + refs := []string{"tool-1", "tool-2", "tool-3"} + + if !ContainsReference(refs, "tool-2") { + t.Error("ContainsReference should return true for existing ref") + } + + if ContainsReference(refs, "tool-4") { + t.Error("ContainsReference should return false for non-existing ref") + } +} + +func TestStripUnresolvedAssistantDestinations(t *testing.T) { + state := NewStateFile() + state.Assistants["existing-assistant"] = "uuid-exists" + + original := map[string]interface{}{ + "destinations": []interface{}{ + map[string]interface{}{ + "type": "assistant", + "assistantId": "new-assistant ## Comment", + }, + map[string]interface{}{ + "type": "number", + "phoneNumber": "+1234567890", + }, + }, + } + + resolved := map[string]interface{}{ + "destinations": []interface{}{ + map[string]interface{}{ + "type": "assistant", + "assistantId": "new-assistant", // Not resolved, assistant doesn't exist + }, + map[string]interface{}{ + "type": "number", + "phoneNumber": "+1234567890", + }, + }, + } + + result := StripUnresolvedAssistantDestinations(resolved, original) + + destinations := result["destinations"].([]interface{}) + if len(destinations) != 1 { + t.Errorf("Expected 1 destination after stripping, got %d", len(destinations)) + } + + // Only the phone number destination should remain + dest := destinations[0].(map[string]interface{}) + if dest["type"] != "number" { + t.Errorf("Expected number destination to remain, got %v", dest["type"]) + } +} + +func TestIsUUID(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"550e8400-e29b-41d4-a716-446655440000", true}, + {"550E8400-E29B-41D4-A716-446655440000", true}, + {"my-resource-id", false}, + {"not-a-uuid", false}, + {"", false}, + {"550e8400-e29b-41d4-a716", false}, // Too short + } + + for _, tt := range tests { + result := IsUUID(tt.input) + if result != tt.expected { + t.Errorf("IsUUID(%q) = %v, expected %v", tt.input, result, tt.expected) + } + } +} diff --git a/pkg/gitops/resources.go b/pkg/gitops/resources.go new file mode 100644 index 0000000..a95c7b6 --- /dev/null +++ b/pkg/gitops/resources.go @@ -0,0 +1,199 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// LoadResources loads all YAML files for a resource type. +func LoadResources(config *Config, rt ResourceType) ([]*ResourceFile, error) { + dir := config.GetResourceDir(rt) + + // If directory doesn't exist, return empty slice + if _, err := os.Stat(dir); os.IsNotExist(err) { + return []*ResourceFile{}, nil + } + + var resources []*ResourceFile + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories + if info.IsDir() { + return nil + } + + // Only process .yml and .yaml files + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".yml" && ext != ".yaml" { + return nil + } + + resource, err := loadResourceFile(path, dir, rt) + if err != nil { + return fmt.Errorf("failed to load %s: %w", path, err) + } + + resources = append(resources, resource) + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to load resources from %s: %w", dir, err) + } + + return resources, nil +} + +// loadResourceFile loads a single YAML resource file. +func loadResourceFile(path, baseDir string, rt ResourceType) (*ResourceFile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + var content map[string]interface{} + if err := yaml.Unmarshal(data, &content); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + + // Generate resource ID from relative path + relPath, err := filepath.Rel(baseDir, path) + if err != nil { + return nil, fmt.Errorf("failed to get relative path: %w", err) + } + + // Remove extension and convert to forward slashes for consistency + resourceID := strings.TrimSuffix(relPath, filepath.Ext(relPath)) + resourceID = filepath.ToSlash(resourceID) + + return &ResourceFile{ + ResourceID: resourceID, + ResourceType: rt, + FilePath: path, + Data: content, + }, nil +} + +// LoadAllResources loads all resources of all types. +func LoadAllResources(config *Config) (*LoadedResources, error) { + loaded := &LoadedResources{} + + tools, err := LoadResources(config, ResourceTypeTools) + if err != nil { + return nil, fmt.Errorf("failed to load tools: %w", err) + } + loaded.Tools = tools + + outputs, err := LoadResources(config, ResourceTypeStructuredOutputs) + if err != nil { + return nil, fmt.Errorf("failed to load structured outputs: %w", err) + } + loaded.StructuredOutputs = outputs + + assistants, err := LoadResources(config, ResourceTypeAssistants) + if err != nil { + return nil, fmt.Errorf("failed to load assistants: %w", err) + } + loaded.Assistants = assistants + + return loaded, nil +} + +// WriteResourceFile writes a resource to a YAML file. +func WriteResourceFile(config *Config, rt ResourceType, resourceID string, data map[string]interface{}) (string, error) { + dir := config.GetResourceDir(rt) + filePath := filepath.Join(dir, resourceID+".yml") + + // Ensure directory exists (including nested directories) + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return "", fmt.Errorf("failed to create directory: %w", err) + } + + // Marshal to YAML + yamlData, err := yaml.Marshal(data) + if err != nil { + return "", fmt.Errorf("failed to marshal YAML: %w", err) + } + + // Write file + if err := os.WriteFile(filePath, yamlData, 0644); err != nil { + return "", fmt.Errorf("failed to write file: %w", err) + } + + return filePath, nil +} + +// GetResourceIDsFromFiles returns all resource IDs from loaded resources. +func GetResourceIDsFromFiles(resources []*ResourceFile) []string { + ids := make([]string, len(resources)) + for i, r := range resources { + ids[i] = r.ResourceID + } + return ids +} + +// Slugify converts a name to a URL-friendly slug. +func Slugify(name string) string { + // Convert to lowercase + slug := strings.ToLower(name) + + // Replace non-alphanumeric characters with hyphens + var result strings.Builder + for _, r := range slug { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + result.WriteRune(r) + } else { + result.WriteRune('-') + } + } + + // Clean up multiple hyphens and trim + slug = result.String() + for strings.Contains(slug, "--") { + slug = strings.ReplaceAll(slug, "--", "-") + } + slug = strings.Trim(slug, "-") + + return slug +} + +// GenerateUniqueResourceID generates a unique resource ID from a name. +func GenerateUniqueResourceID(name string, existingIDs map[string]bool) string { + base := Slugify(name) + if base == "" { + base = "resource" + } + + resourceID := base + counter := 1 + + for existingIDs[resourceID] { + resourceID = fmt.Sprintf("%s-%d", base, counter) + counter++ + } + + return resourceID +} diff --git a/pkg/gitops/resources_test.go b/pkg/gitops/resources_test.go new file mode 100644 index 0000000..5eb0bd0 --- /dev/null +++ b/pkg/gitops/resources_test.go @@ -0,0 +1,289 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSlugify(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Hello World", "hello-world"}, + {"Test_Resource", "test-resource"}, + {"My Assistant!", "my-assistant"}, + {"UPPERCASE", "uppercase"}, + {"special@#$chars", "special-chars"}, + {"leading---dashes", "leading-dashes"}, + {"trailing---", "trailing"}, + {"123-numbers", "123-numbers"}, + {"multiple___underscores", "multiple-underscores"}, + {"", ""}, + } + + for _, tt := range tests { + result := Slugify(tt.input) + if result != tt.expected { + t.Errorf("Slugify(%q) = %q, expected %q", tt.input, result, tt.expected) + } + } +} + +func TestGenerateUniqueResourceID(t *testing.T) { + existing := map[string]bool{ + "my-resource": true, + "my-resource-1": true, + } + + // Test generating ID for new resource + result := GenerateUniqueResourceID("New Resource", existing) + if result != "new-resource" { + t.Errorf("GenerateUniqueResourceID for new resource = %q, expected %q", result, "new-resource") + } + + // Test generating ID for existing resource (should get suffix) + result = GenerateUniqueResourceID("My Resource", existing) + if result != "my-resource-2" { + t.Errorf("GenerateUniqueResourceID for existing = %q, expected %q", result, "my-resource-2") + } +} + +func TestWriteAndLoadResourceFile(t *testing.T) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "gitops-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + cfg := NewConfig(tempDir) + + // Create tools directory + toolsDir := cfg.GetResourceDir(ResourceTypeTools) + if err := os.MkdirAll(toolsDir, 0755); err != nil { + t.Fatalf("Failed to create tools dir: %v", err) + } + + // Write a test resource + testData := map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "test_function", + "description": "A test function", + }, + } + + filePath, err := WriteResourceFile(cfg, ResourceTypeTools, "test-tool", testData) + if err != nil { + t.Fatalf("WriteResourceFile failed: %v", err) + } + + // Verify file exists + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Errorf("File was not created at %s", filePath) + } + + // Read back the file + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + // Verify content contains expected values + contentStr := string(content) + if !containsStr(contentStr, "test_function") { + t.Error("File content should contain 'test_function'") + } + if !containsStr(contentStr, "A test function") { + t.Error("File content should contain 'A test function'") + } +} + +func TestLoadResources(t *testing.T) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "gitops-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + cfg := NewConfig(tempDir) + + // Create tools directory with a test file + toolsDir := cfg.GetResourceDir(ResourceTypeTools) + if err := os.MkdirAll(toolsDir, 0755); err != nil { + t.Fatalf("Failed to create tools dir: %v", err) + } + + // Create a nested directory + nestedDir := filepath.Join(toolsDir, "production") + if err := os.MkdirAll(nestedDir, 0755); err != nil { + t.Fatalf("Failed to create nested dir: %v", err) + } + + // Write test files + tool1 := `type: function +function: + name: tool1 + description: First tool +` + tool2 := `type: function +function: + name: tool2 + description: Second tool +` + + if err := os.WriteFile(filepath.Join(toolsDir, "tool1.yaml"), []byte(tool1), 0644); err != nil { + t.Fatalf("Failed to write tool1: %v", err) + } + if err := os.WriteFile(filepath.Join(nestedDir, "tool2.yaml"), []byte(tool2), 0644); err != nil { + t.Fatalf("Failed to write tool2: %v", err) + } + + // Load resources + resources, err := LoadResources(cfg, ResourceTypeTools) + if err != nil { + t.Fatalf("LoadResources failed: %v", err) + } + + if len(resources) != 2 { + t.Errorf("LoadResources returned %d resources, expected 2", len(resources)) + } + + // Verify resource IDs + ids := make(map[string]bool) + for _, r := range resources { + ids[r.ResourceID] = true + } + + if !ids["tool1"] { + t.Error("Missing resource ID 'tool1'") + } + if !ids["production-tool2"] { + t.Error("Missing resource ID 'production-tool2' (nested should include folder prefix)") + } +} + +func TestLoadAllResources(t *testing.T) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "gitops-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + cfg := NewConfig(tempDir) + + // Create all resource directories + for _, rt := range AllResourceTypes() { + dir := cfg.GetResourceDir(rt) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create dir for %s: %v", rt, err) + } + } + + // Write test files + assistantYAML := `name: Test Assistant +model: + provider: openai + model: gpt-4o +` + toolYAML := `type: function +function: + name: test_tool +` + outputYAML := `name: Test Output +schema: + type: object +` + + if err := os.WriteFile( + filepath.Join(cfg.GetResourceDir(ResourceTypeAssistants), "test-assistant.yaml"), + []byte(assistantYAML), 0644, + ); err != nil { + t.Fatalf("Failed to write assistant: %v", err) + } + + if err := os.WriteFile( + filepath.Join(cfg.GetResourceDir(ResourceTypeTools), "test-tool.yaml"), + []byte(toolYAML), 0644, + ); err != nil { + t.Fatalf("Failed to write tool: %v", err) + } + + if err := os.WriteFile( + filepath.Join(cfg.GetResourceDir(ResourceTypeStructuredOutputs), "test-output.yaml"), + []byte(outputYAML), 0644, + ); err != nil { + t.Fatalf("Failed to write output: %v", err) + } + + // Load all resources + loaded, err := LoadAllResources(cfg) + if err != nil { + t.Fatalf("LoadAllResources failed: %v", err) + } + + if len(loaded.Assistants) != 1 { + t.Errorf("Expected 1 assistant, got %d", len(loaded.Assistants)) + } + if len(loaded.Tools) != 1 { + t.Errorf("Expected 1 tool, got %d", len(loaded.Tools)) + } + if len(loaded.StructuredOutputs) != 1 { + t.Errorf("Expected 1 structured output, got %d", len(loaded.StructuredOutputs)) + } +} + +func TestGetResourceIDsFromFiles(t *testing.T) { + files := []*ResourceFile{ + {ResourceID: "resource-1"}, + {ResourceID: "resource-2"}, + {ResourceID: "resource-3"}, + } + + ids := GetResourceIDsFromFiles(files) + + if len(ids) != 3 { + t.Errorf("GetResourceIDsFromFiles returned %d IDs, expected 3", len(ids)) + } + + expected := map[string]bool{ + "resource-1": true, + "resource-2": true, + "resource-3": true, + } + + for _, id := range ids { + if !expected[id] { + t.Errorf("Unexpected ID: %s", id) + } + } +} + +// Helper function +func containsStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/pkg/gitops/state.go b/pkg/gitops/state.go new file mode 100644 index 0000000..5998d07 --- /dev/null +++ b/pkg/gitops/state.go @@ -0,0 +1,87 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +import ( + "encoding/json" + "fmt" + "os" +) + +// LoadState loads the state file from disk. +// If the file doesn't exist, returns an empty state. +func LoadState(path string) (*StateFile, error) { + state := NewStateFile() + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return state, nil + } + return nil, fmt.Errorf("failed to read state file: %w", err) + } + + if err := json.Unmarshal(data, state); err != nil { + return nil, fmt.Errorf("failed to parse state file: %w", err) + } + + // Ensure all maps are initialized + if state.Assistants == nil { + state.Assistants = make(map[string]string) + } + if state.Tools == nil { + state.Tools = make(map[string]string) + } + if state.StructuredOutputs == nil { + state.StructuredOutputs = make(map[string]string) + } + + return state, nil +} + +// SaveState saves the state file to disk. +func SaveState(path string, state *StateFile) error { + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal state: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("failed to write state file: %w", err) + } + + return nil +} + +// BuildReverseMap creates a UUID -> resourceID map for a resource type. +func BuildReverseMap(state *StateFile, rt ResourceType) map[string]string { + section := state.GetSection(rt) + result := make(map[string]string) + for resourceID, uuid := range section { + result[uuid] = resourceID + } + return result +} + +// GetResourceIDs returns all resource IDs for a given type. +func GetResourceIDs(state *StateFile, rt ResourceType) []string { + section := state.GetSection(rt) + ids := make([]string, 0, len(section)) + for id := range section { + ids = append(ids, id) + } + return ids +} diff --git a/pkg/gitops/state_test.go b/pkg/gitops/state_test.go new file mode 100644 index 0000000..1a495d4 --- /dev/null +++ b/pkg/gitops/state_test.go @@ -0,0 +1,167 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSaveAndLoadState(t *testing.T) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "gitops-state-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + statePath := filepath.Join(tempDir, ".vapi-state.json") + + // Create and populate state + state := NewStateFile() + state.Assistants["my-assistant"] = "uuid-assistant-123" + state.Tools["my-tool"] = "uuid-tool-456" + state.StructuredOutputs["my-output"] = "uuid-output-789" + + // Save state + if err := SaveState(statePath, state); err != nil { + t.Fatalf("SaveState failed: %v", err) + } + + // Verify file exists + if _, err := os.Stat(statePath); os.IsNotExist(err) { + t.Error("State file was not created") + } + + // Load state + loaded, err := LoadState(statePath) + if err != nil { + t.Fatalf("LoadState failed: %v", err) + } + + // Verify loaded content + if loaded.Assistants["my-assistant"] != "uuid-assistant-123" { + t.Errorf("Assistants mismatch: expected uuid-assistant-123, got %s", loaded.Assistants["my-assistant"]) + } + if loaded.Tools["my-tool"] != "uuid-tool-456" { + t.Errorf("Tools mismatch: expected uuid-tool-456, got %s", loaded.Tools["my-tool"]) + } + if loaded.StructuredOutputs["my-output"] != "uuid-output-789" { + t.Errorf("StructuredOutputs mismatch: expected uuid-output-789, got %s", loaded.StructuredOutputs["my-output"]) + } +} + +func TestLoadState_NonExistent(t *testing.T) { + // Loading non-existent state should return empty state + loaded, err := LoadState("/nonexistent/path/.vapi-state.json") + if err != nil { + t.Fatalf("LoadState should not error for non-existent file: %v", err) + } + + if loaded.Assistants == nil { + t.Error("Assistants map should be initialized") + } + if loaded.Tools == nil { + t.Error("Tools map should be initialized") + } + if loaded.StructuredOutputs == nil { + t.Error("StructuredOutputs map should be initialized") + } + + if len(loaded.Assistants) != 0 { + t.Error("Assistants should be empty") + } +} + +func TestLoadState_InvalidJSON(t *testing.T) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "gitops-state-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + statePath := filepath.Join(tempDir, ".vapi-state.json") + + // Write invalid JSON + if err := os.WriteFile(statePath, []byte("not valid json"), 0644); err != nil { + t.Fatalf("Failed to write invalid JSON: %v", err) + } + + // Load should fail + _, err = LoadState(statePath) + if err == nil { + t.Error("LoadState should fail for invalid JSON") + } +} + +func TestSaveState_CreatesDirectory(t *testing.T) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "gitops-state-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Path with nested directory that doesn't exist + statePath := filepath.Join(tempDir, "nested", "dir", ".vapi-state.json") + + state := NewStateFile() + state.Tools["test"] = "uuid-test" + + // Save should create directory + if err := SaveState(statePath, state); err != nil { + t.Fatalf("SaveState failed: %v", err) + } + + // Verify file exists + if _, err := os.Stat(statePath); os.IsNotExist(err) { + t.Error("State file was not created in nested directory") + } +} + +func TestStateFile_Concurrent(t *testing.T) { + state := NewStateFile() + + // Simulate concurrent operations (basic test) + done := make(chan bool, 3) + + go func() { + for i := 0; i < 100; i++ { + state.SetUUID(ResourceTypeAssistants, "assistant", "uuid") + } + done <- true + }() + + go func() { + for i := 0; i < 100; i++ { + state.SetUUID(ResourceTypeTools, "tool", "uuid") + } + done <- true + }() + + go func() { + for i := 0; i < 100; i++ { + state.GetSection(ResourceTypeAssistants) + } + done <- true + }() + + <-done + <-done + <-done +} diff --git a/pkg/gitops/types.go b/pkg/gitops/types.go new file mode 100644 index 0000000..bac33e1 --- /dev/null +++ b/pkg/gitops/types.go @@ -0,0 +1,154 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +// ResourceType represents the type of Vapi resource. +type ResourceType string + +const ( + ResourceTypeAssistants ResourceType = "assistants" + ResourceTypeTools ResourceType = "tools" + ResourceTypeStructuredOutputs ResourceType = "structuredOutputs" +) + +// AllResourceTypes returns all supported resource types in dependency order. +func AllResourceTypes() []ResourceType { + return []ResourceType{ + ResourceTypeTools, + ResourceTypeStructuredOutputs, + ResourceTypeAssistants, + } +} + +// DeleteOrder returns resource types in reverse dependency order for safe deletion. +func DeleteOrder() []ResourceType { + return []ResourceType{ + ResourceTypeAssistants, + ResourceTypeStructuredOutputs, + ResourceTypeTools, + } +} + +// StateFile represents the mapping between resource IDs and Vapi UUIDs. +type StateFile struct { + Assistants map[string]string `json:"assistants"` + Tools map[string]string `json:"tools"` + StructuredOutputs map[string]string `json:"structuredOutputs"` +} + +// NewStateFile creates an empty state file. +func NewStateFile() *StateFile { + return &StateFile{ + Assistants: make(map[string]string), + Tools: make(map[string]string), + StructuredOutputs: make(map[string]string), + } +} + +// GetSection returns the state section for a given resource type. +func (s *StateFile) GetSection(rt ResourceType) map[string]string { + switch rt { + case ResourceTypeAssistants: + return s.Assistants + case ResourceTypeTools: + return s.Tools + case ResourceTypeStructuredOutputs: + return s.StructuredOutputs + default: + return nil + } +} + +// SetUUID sets the UUID for a resource ID in the appropriate section. +func (s *StateFile) SetUUID(rt ResourceType, resourceID, uuid string) { + switch rt { + case ResourceTypeAssistants: + s.Assistants[resourceID] = uuid + case ResourceTypeTools: + s.Tools[resourceID] = uuid + case ResourceTypeStructuredOutputs: + s.StructuredOutputs[resourceID] = uuid + } +} + +// DeleteResource removes a resource from the state. +func (s *StateFile) DeleteResource(rt ResourceType, resourceID string) { + switch rt { + case ResourceTypeAssistants: + delete(s.Assistants, resourceID) + case ResourceTypeTools: + delete(s.Tools, resourceID) + case ResourceTypeStructuredOutputs: + delete(s.StructuredOutputs, resourceID) + } +} + +// ResourceFile represents a loaded resource from a YAML file. +type ResourceFile struct { + ResourceID string // Filename without extension (e.g., "transfer-call" or "company-1/transfer-call") + ResourceType ResourceType // The type of resource + FilePath string // Full path to the YAML file + Data map[string]interface{} // Parsed YAML content +} + +// LoadedResources holds all loaded resources by type. +type LoadedResources struct { + Tools []*ResourceFile + StructuredOutputs []*ResourceFile + Assistants []*ResourceFile +} + +// GetByType returns the resources for a given type. +func (lr *LoadedResources) GetByType(rt ResourceType) []*ResourceFile { + switch rt { + case ResourceTypeTools: + return lr.Tools + case ResourceTypeStructuredOutputs: + return lr.StructuredOutputs + case ResourceTypeAssistants: + return lr.Assistants + default: + return nil + } +} + +// OrphanedResource represents a resource in state but not in filesystem. +type OrphanedResource struct { + ResourceID string + UUID string +} + +// VapiResource represents a resource fetched from the Vapi API. +type VapiResource struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Data map[string]interface{} `json:"-"` // Raw JSON data +} + +// ApplyResult holds the result of applying a resource. +type ApplyResult struct { + ResourceID string + UUID string + Created bool + Updated bool + Error error +} + +// PullStats tracks pull operation statistics. +type PullStats struct { + Created int + Updated int +} diff --git a/pkg/gitops/types_test.go b/pkg/gitops/types_test.go new file mode 100644 index 0000000..3b6bd42 --- /dev/null +++ b/pkg/gitops/types_test.go @@ -0,0 +1,214 @@ +/* +Copyright Ā© 2025 Vapi, Inc. + +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gitops + +import ( + "testing" +) + +func TestNewStateFile(t *testing.T) { + state := NewStateFile() + + if state.Assistants == nil { + t.Error("Assistants map should be initialized") + } + if state.Tools == nil { + t.Error("Tools map should be initialized") + } + if state.StructuredOutputs == nil { + t.Error("StructuredOutputs map should be initialized") + } +} + +func TestStateFile_GetSection(t *testing.T) { + state := NewStateFile() + state.Assistants["test-assistant"] = "uuid-1" + state.Tools["test-tool"] = "uuid-2" + state.StructuredOutputs["test-output"] = "uuid-3" + + tests := []struct { + rt ResourceType + expected string + }{ + {ResourceTypeAssistants, "uuid-1"}, + {ResourceTypeTools, "uuid-2"}, + {ResourceTypeStructuredOutputs, "uuid-3"}, + } + + for _, tt := range tests { + section := state.GetSection(tt.rt) + if section == nil { + t.Errorf("GetSection(%s) returned nil", tt.rt) + continue + } + + var key string + switch tt.rt { + case ResourceTypeAssistants: + key = "test-assistant" + case ResourceTypeTools: + key = "test-tool" + case ResourceTypeStructuredOutputs: + key = "test-output" + } + + if section[key] != tt.expected { + t.Errorf("GetSection(%s)[%s] = %s, expected %s", tt.rt, key, section[key], tt.expected) + } + } +} + +func TestStateFile_SetUUID(t *testing.T) { + state := NewStateFile() + + state.SetUUID(ResourceTypeAssistants, "my-assistant", "uuid-a") + state.SetUUID(ResourceTypeTools, "my-tool", "uuid-t") + state.SetUUID(ResourceTypeStructuredOutputs, "my-output", "uuid-o") + + if state.Assistants["my-assistant"] != "uuid-a" { + t.Errorf("SetUUID for assistant failed, got %s", state.Assistants["my-assistant"]) + } + if state.Tools["my-tool"] != "uuid-t" { + t.Errorf("SetUUID for tool failed, got %s", state.Tools["my-tool"]) + } + if state.StructuredOutputs["my-output"] != "uuid-o" { + t.Errorf("SetUUID for structured output failed, got %s", state.StructuredOutputs["my-output"]) + } +} + +func TestStateFile_DeleteResource(t *testing.T) { + state := NewStateFile() + state.Assistants["to-delete"] = "uuid-1" + state.Tools["to-delete"] = "uuid-2" + state.StructuredOutputs["to-delete"] = "uuid-3" + + state.DeleteResource(ResourceTypeAssistants, "to-delete") + state.DeleteResource(ResourceTypeTools, "to-delete") + state.DeleteResource(ResourceTypeStructuredOutputs, "to-delete") + + if _, exists := state.Assistants["to-delete"]; exists { + t.Error("DeleteResource for assistant failed") + } + if _, exists := state.Tools["to-delete"]; exists { + t.Error("DeleteResource for tool failed") + } + if _, exists := state.StructuredOutputs["to-delete"]; exists { + t.Error("DeleteResource for structured output failed") + } +} + +func TestAllResourceTypes(t *testing.T) { + types := AllResourceTypes() + + if len(types) != 3 { + t.Errorf("AllResourceTypes() returned %d types, expected 3", len(types)) + } + + expected := map[ResourceType]bool{ + ResourceTypeAssistants: true, + ResourceTypeTools: true, + ResourceTypeStructuredOutputs: true, + } + + for _, rt := range types { + if !expected[rt] { + t.Errorf("Unexpected resource type: %s", rt) + } + } +} + +func TestApplyOrder(t *testing.T) { + order := ApplyOrder() + + // Tools should come before assistants (dependency order) + if len(order) != 3 { + t.Errorf("ApplyOrder() returned %d items, expected 3", len(order)) + } + + // First should be tools (no dependencies) + if order[0] != ResourceTypeTools { + t.Errorf("First in ApplyOrder should be tools, got %s", order[0]) + } + + // Last should be assistants (depends on tools and outputs) + if order[2] != ResourceTypeAssistants { + t.Errorf("Last in ApplyOrder should be assistants, got %s", order[2]) + } +} + +func TestDeleteOrder(t *testing.T) { + order := DeleteOrder() + + // Assistants should be deleted first (reverse of apply) + if len(order) != 3 { + t.Errorf("DeleteOrder() returned %d items, expected 3", len(order)) + } + + // First should be assistants (to remove references) + if order[0] != ResourceTypeAssistants { + t.Errorf("First in DeleteOrder should be assistants, got %s", order[0]) + } + + // Last should be tools + if order[2] != ResourceTypeTools { + t.Errorf("Last in DeleteOrder should be tools, got %s", order[2]) + } +} + +func TestLoadedResources_GetByType(t *testing.T) { + loaded := &LoadedResources{ + Assistants: []*ResourceFile{ + {ResourceID: "assistant-1"}, + }, + Tools: []*ResourceFile{ + {ResourceID: "tool-1"}, + {ResourceID: "tool-2"}, + }, + StructuredOutputs: []*ResourceFile{ + {ResourceID: "output-1"}, + }, + } + + tests := []struct { + rt ResourceType + expected int + }{ + {ResourceTypeAssistants, 1}, + {ResourceTypeTools, 2}, + {ResourceTypeStructuredOutputs, 1}, + } + + for _, tt := range tests { + result := loaded.GetByType(tt.rt) + if len(result) != tt.expected { + t.Errorf("GetByType(%s) returned %d items, expected %d", tt.rt, len(result), tt.expected) + } + } +} + +func TestPullStats(t *testing.T) { + stats := &PullStats{ + Created: 5, + Updated: 3, + } + + if stats.Created != 5 { + t.Errorf("Created = %d, expected 5", stats.Created) + } + if stats.Updated != 3 { + t.Errorf("Updated = %d, expected 3", stats.Updated) + } +}