diff --git a/pkg/cli/codespace_init.go b/pkg/cli/codespace_init.go new file mode 100644 index 0000000000..746d627e65 --- /dev/null +++ b/pkg/cli/codespace_init.go @@ -0,0 +1,334 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/githubnext/gh-aw/pkg/logger" +) + +var codespaceInitLog = logger.New("cli:codespace_init") + +// DevcontainerRepositoryPermissions represents the permissions for a repository in devcontainer.json +type DevcontainerRepositoryPermissions struct { + Actions string `json:"actions,omitempty"` + Contents string `json:"contents,omitempty"` + Workflows string `json:"workflows,omitempty"` + Issues string `json:"issues,omitempty"` + PullRequests string `json:"pull-requests,omitempty"` + Discussions string `json:"discussions,omitempty"` + Metadata string `json:"metadata,omitempty"` +} + +// DevcontainerRepositoryConfig represents repository configuration in devcontainer.json +type DevcontainerRepositoryConfig struct { + Permissions DevcontainerRepositoryPermissions `json:"permissions"` +} + +// DevcontainerCodespaces represents the codespaces section of devcontainer.json +type DevcontainerCodespaces struct { + Repositories map[string]DevcontainerRepositoryConfig `json:"repositories"` +} + +// DevcontainerCustomizations represents the customizations section of devcontainer.json +// Uses map[string]any to preserve all custom fields +type DevcontainerCustomizations struct { + Codespaces *DevcontainerCodespaces `json:"codespaces,omitempty"` + // Store additional fields not explicitly defined + Extra map[string]any `json:"-"` +} + +// MarshalJSON implements json.Marshaler for DevcontainerCustomizations +func (c *DevcontainerCustomizations) MarshalJSON() ([]byte, error) { + // Create a map to hold all fields + result := make(map[string]any) + + // Add extra fields first + for k, v := range c.Extra { + result[k] = v + } + + // Add codespaces if present + if c.Codespaces != nil { + result["codespaces"] = c.Codespaces + } + + return json.Marshal(result) +} + +// UnmarshalJSON implements json.Unmarshaler for DevcontainerCustomizations +func (c *DevcontainerCustomizations) UnmarshalJSON(data []byte) error { + // First unmarshal into a map + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + c.Extra = make(map[string]any) + + // Process each field + for key, value := range raw { + if key == "codespaces" { + // Handle codespaces specially + c.Codespaces = &DevcontainerCodespaces{} + if err := json.Unmarshal(value, c.Codespaces); err != nil { + return err + } + } else { + // Store other fields in Extra + var v any + if err := json.Unmarshal(value, &v); err != nil { + return err + } + c.Extra[key] = v + } + } + + return nil +} + +// Devcontainer represents the structure of devcontainer.json +// Uses map[string]any to preserve all custom fields +type Devcontainer struct { + Image string `json:"image,omitempty"` + Name string `json:"name,omitempty"` + Customizations *DevcontainerCustomizations `json:"customizations,omitempty"` + // Store additional fields not explicitly defined + Extra map[string]any `json:"-"` +} + +// MarshalJSON implements json.Marshaler for Devcontainer +func (d *Devcontainer) MarshalJSON() ([]byte, error) { + // Create a map to hold all fields + result := make(map[string]any) + + // Add extra fields first + for k, v := range d.Extra { + result[k] = v + } + + // Add known fields if present + if d.Image != "" { + result["image"] = d.Image + } + if d.Name != "" { + result["name"] = d.Name + } + if d.Customizations != nil { + result["customizations"] = d.Customizations + } + + return json.Marshal(result) +} + +// UnmarshalJSON implements json.Unmarshaler for Devcontainer +func (d *Devcontainer) UnmarshalJSON(data []byte) error { + // First unmarshal into a map + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + d.Extra = make(map[string]any) + + // Process each field + for key, value := range raw { + switch key { + case "image": + if err := json.Unmarshal(value, &d.Image); err != nil { + return err + } + case "name": + if err := json.Unmarshal(value, &d.Name); err != nil { + return err + } + case "customizations": + d.Customizations = &DevcontainerCustomizations{} + if err := json.Unmarshal(value, d.Customizations); err != nil { + return err + } + default: + // Store other fields in Extra + var v any + if err := json.Unmarshal(value, &v); err != nil { + return err + } + d.Extra[key] = v + } + } + + return nil +} + +// ensureDevcontainerCodespace creates or updates .devcontainer/devcontainer.json with codespace permissions +func ensureDevcontainerCodespace(verbose bool) error { + codespaceInitLog.Print("Ensuring devcontainer.json exists with codespace permissions") + + // Get the current repository slug + repoSlug, err := GetCurrentRepoSlug() + if err != nil { + codespaceInitLog.Printf("Failed to get current repository slug: %v", err) + return fmt.Errorf("failed to get current repository: %w", err) + } + codespaceInitLog.Printf("Current repository: %s", repoSlug) + + // Check for devcontainer.json in multiple locations + devcontainerPaths := []string{ + ".devcontainer/devcontainer.json", + ".devcontainer.json", + } + + var devcontainerPath string + var existingConfig *Devcontainer + + // Look for existing devcontainer.json + for _, path := range devcontainerPaths { + if data, err := os.ReadFile(path); err == nil { + codespaceInitLog.Printf("Found existing devcontainer.json at: %s", path) + devcontainerPath = path + existingConfig = &Devcontainer{} + if err := json.Unmarshal(data, existingConfig); err != nil { + codespaceInitLog.Printf("Failed to parse existing devcontainer.json, will create new one: %v", err) + existingConfig = nil + } + break + } + } + + // If no existing config found, create a new one in .devcontainer/ + if devcontainerPath == "" { + devcontainerPath = ".devcontainer/devcontainer.json" + codespaceInitLog.Printf("No existing devcontainer.json found, will create at: %s", devcontainerPath) + } + + // Create or update the configuration + var config *Devcontainer + if existingConfig != nil { + config = existingConfig + codespaceInitLog.Print("Using existing configuration") + } else { + config = &Devcontainer{ + Image: "mcr.microsoft.com/devcontainers/universal:2", + } + codespaceInitLog.Print("Creating new basic configuration") + } + + // Ensure customizations exists + if config.Customizations == nil { + config.Customizations = &DevcontainerCustomizations{} + } + + // Ensure codespaces exists + if config.Customizations.Codespaces == nil { + config.Customizations.Codespaces = &DevcontainerCodespaces{ + Repositories: make(map[string]DevcontainerRepositoryConfig), + } + } + + // Ensure repositories map exists + if config.Customizations.Codespaces.Repositories == nil { + config.Customizations.Codespaces.Repositories = make(map[string]DevcontainerRepositoryConfig) + } + + // Add permissions for the current repository + currentRepoPerms := DevcontainerRepositoryConfig{ + Permissions: DevcontainerRepositoryPermissions{ + Actions: "write", + Contents: "write", + Workflows: "write", + Issues: "write", + PullRequests: "write", + Discussions: "write", + }, + } + + // Check if current repo already has permissions configured + if existingPerms, exists := config.Customizations.Codespaces.Repositories[repoSlug]; exists { + codespaceInitLog.Printf("Repository %s already has permissions configured", repoSlug) + // Merge permissions - only update if not already set + existingPerms.Permissions = mergePermissions(existingPerms.Permissions, currentRepoPerms.Permissions) + config.Customizations.Codespaces.Repositories[repoSlug] = existingPerms + } else { + codespaceInitLog.Printf("Adding permissions for repository: %s", repoSlug) + config.Customizations.Codespaces.Repositories[repoSlug] = currentRepoPerms + } + + // Add read permissions for githubnext/gh-aw repository + ghAwRepo := "githubnext/gh-aw" + ghAwPerms := DevcontainerRepositoryConfig{ + Permissions: DevcontainerRepositoryPermissions{ + Contents: "read", + Metadata: "read", + }, + } + + // Check if gh-aw repo already has permissions configured + if existingPerms, exists := config.Customizations.Codespaces.Repositories[ghAwRepo]; exists { + codespaceInitLog.Printf("Repository %s already has permissions configured", ghAwRepo) + // Merge permissions - only update if not already set + existingPerms.Permissions = mergePermissions(existingPerms.Permissions, ghAwPerms.Permissions) + config.Customizations.Codespaces.Repositories[ghAwRepo] = existingPerms + } else { + codespaceInitLog.Printf("Adding read permissions for repository: %s", ghAwRepo) + config.Customizations.Codespaces.Repositories[ghAwRepo] = ghAwPerms + } + + // Marshal to JSON with indentation + data, err := json.MarshalIndent(config, "", "\t") + if err != nil { + return fmt.Errorf("failed to marshal devcontainer.json: %w", err) + } + + // Ensure directory exists + dir := filepath.Dir(devcontainerPath) + if dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + codespaceInitLog.Printf("Ensured directory exists: %s", dir) + } + + // Write the file + if err := os.WriteFile(devcontainerPath, data, 0644); err != nil { + return fmt.Errorf("failed to write devcontainer.json: %w", err) + } + + codespaceInitLog.Printf("Successfully wrote devcontainer.json to: %s", devcontainerPath) + + if verbose { + fmt.Fprintf(os.Stderr, "Configured %s with codespace permissions\n", devcontainerPath) + fmt.Fprintf(os.Stderr, " - Added permissions for %s\n", repoSlug) + fmt.Fprintf(os.Stderr, " - Added read permissions for %s\n", ghAwRepo) + } + + return nil +} + +// mergePermissions merges default permissions into existing permissions, only setting values that are empty +func mergePermissions(existing, defaults DevcontainerRepositoryPermissions) DevcontainerRepositoryPermissions { + result := existing + if result.Actions == "" && defaults.Actions != "" { + result.Actions = defaults.Actions + } + if result.Contents == "" && defaults.Contents != "" { + result.Contents = defaults.Contents + } + if result.Workflows == "" && defaults.Workflows != "" { + result.Workflows = defaults.Workflows + } + if result.Issues == "" && defaults.Issues != "" { + result.Issues = defaults.Issues + } + if result.PullRequests == "" && defaults.PullRequests != "" { + result.PullRequests = defaults.PullRequests + } + if result.Discussions == "" && defaults.Discussions != "" { + result.Discussions = defaults.Discussions + } + if result.Metadata == "" && defaults.Metadata != "" { + result.Metadata = defaults.Metadata + } + return result +} diff --git a/pkg/cli/init.go b/pkg/cli/init.go index 480e8c2398..7c59de19ea 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -12,7 +12,7 @@ import ( var initLog = logger.New("cli:init") // InitRepository initializes the repository for agentic workflows -func InitRepository(verbose bool, mcp bool) error { +func InitRepository(verbose bool, mcp bool, codespace bool) error { initLog.Print("Starting repository initialization for agentic workflows") // Ensure we're in a git repository @@ -105,6 +105,20 @@ func InitRepository(verbose bool, mcp bool) error { } } + // Configure Codespace if requested + if codespace { + initLog.Print("Configuring GitHub Codespace permissions") + + // Create or update devcontainer.json + if err := ensureDevcontainerCodespace(verbose); err != nil { + initLog.Printf("Failed to configure devcontainer.json: %v", err) + return fmt.Errorf("failed to configure devcontainer.json: %w", err) + } + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Configured devcontainer.json for Codespaces")) + } + } + initLog.Print("Repository initialization completed successfully") // Display success message with next steps @@ -115,6 +129,10 @@ func InitRepository(verbose bool, mcp bool) error { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("GitHub Copilot Agent MCP integration configured")) fmt.Fprintln(os.Stderr, "") } + if codespace { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("GitHub Codespace permissions configured")) + fmt.Fprintln(os.Stderr, "") + } fmt.Fprintln(os.Stderr, console.FormatInfoMessage("To create a workflow, launch Copilot CLI: npx @github/copilot")) fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Then type /agent and select create-agentic-workflow")) fmt.Fprintln(os.Stderr, "") diff --git a/pkg/cli/init_codespace_test.go b/pkg/cli/init_codespace_test.go new file mode 100644 index 0000000000..65fc49dba9 --- /dev/null +++ b/pkg/cli/init_codespace_test.go @@ -0,0 +1,469 @@ +package cli + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/githubnext/gh-aw/pkg/testutil" +) + +func TestInitRepository_WithCodespace(t *testing.T) { + // Clear repository cache before test + ClearCurrentRepoSlugCache() + + // Create a temporary directory for testing + tempDir := testutil.TempDir(t, "test-*") + + // Change to temp directory + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(oldWd) + }() + err = os.Chdir(tempDir) + if err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Initialize git repo with a remote + if err := exec.Command("git", "init").Run(); err != nil { + t.Fatalf("Failed to init git repo: %v", err) + } + // Configure git + exec.Command("git", "config", "user.name", "Test User").Run() + exec.Command("git", "config", "user.email", "test@example.com").Run() + if err := exec.Command("git", "remote", "add", "origin", "https://github.com/test/example-repo.git").Run(); err != nil { + t.Fatalf("Failed to add git remote: %v", err) + } + + // Call the function with Codespace flag + err = InitRepository(false, false, true) + if err != nil { + t.Fatalf("InitRepository() with Codespace returned error: %v", err) + } + + // Verify standard files were created + gitAttributesPath := filepath.Join(tempDir, ".gitattributes") + if _, err := os.Stat(gitAttributesPath); os.IsNotExist(err) { + t.Errorf("Expected .gitattributes file to exist") + } + + // Verify devcontainer.json was created + devcontainerPath := filepath.Join(tempDir, ".devcontainer", "devcontainer.json") + if _, err := os.Stat(devcontainerPath); os.IsNotExist(err) { + t.Errorf("Expected .devcontainer/devcontainer.json to exist") + } else { + // Verify content contains key elements + content, err := os.ReadFile(devcontainerPath) + if err != nil { + t.Fatalf("Failed to read devcontainer.json: %v", err) + } + + var config Devcontainer + if err := json.Unmarshal(content, &config); err != nil { + t.Fatalf("Failed to parse devcontainer.json: %v", err) + } + + // Verify basic structure + if config.Image == "" { + t.Errorf("Expected image to be set in devcontainer.json") + } + + if config.Customizations == nil { + t.Fatalf("Expected customizations to exist in devcontainer.json") + } + + if config.Customizations.Codespaces == nil { + t.Fatalf("Expected codespaces section to exist in devcontainer.json") + } + + repos := config.Customizations.Codespaces.Repositories + if repos == nil { + t.Fatalf("Expected repositories to exist in codespaces section") + } + + // Verify current repository has correct permissions + currentRepo, exists := repos["test/example-repo"] + if !exists { + t.Errorf("Expected test/example-repo to be in repositories") + } else { + perms := currentRepo.Permissions + if perms.Actions != "write" { + t.Errorf("Expected actions permission to be 'write', got '%s'", perms.Actions) + } + if perms.Contents != "write" { + t.Errorf("Expected contents permission to be 'write', got '%s'", perms.Contents) + } + if perms.Workflows != "write" { + t.Errorf("Expected workflows permission to be 'write', got '%s'", perms.Workflows) + } + if perms.Issues != "write" { + t.Errorf("Expected issues permission to be 'write', got '%s'", perms.Issues) + } + if perms.PullRequests != "write" { + t.Errorf("Expected pull-requests permission to be 'write', got '%s'", perms.PullRequests) + } + if perms.Discussions != "write" { + t.Errorf("Expected discussions permission to be 'write', got '%s'", perms.Discussions) + } + } + + // Verify githubnext/gh-aw has read permissions + ghAwRepo, exists := repos["githubnext/gh-aw"] + if !exists { + t.Errorf("Expected githubnext/gh-aw to be in repositories") + } else { + perms := ghAwRepo.Permissions + if perms.Contents != "read" { + t.Errorf("Expected contents permission to be 'read' for gh-aw, got '%s'", perms.Contents) + } + if perms.Metadata != "read" { + t.Errorf("Expected metadata permission to be 'read' for gh-aw, got '%s'", perms.Metadata) + } + } + } +} + +func TestInitRepository_Codespace_Idempotent(t *testing.T) { + // Clear repository cache before test + ClearCurrentRepoSlugCache() + + // Create a temporary directory for testing + tempDir := testutil.TempDir(t, "test-*") + + // Change to temp directory + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(oldWd) + }() + err = os.Chdir(tempDir) + if err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Initialize git repo with a remote + if err := exec.Command("git", "init").Run(); err != nil { + t.Fatalf("Failed to init git repo: %v", err) + } + // Configure git + exec.Command("git", "config", "user.name", "Test User").Run() + exec.Command("git", "config", "user.email", "test@example.com").Run() + if err := exec.Command("git", "remote", "add", "origin", "https://github.com/test/example-repo.git").Run(); err != nil { + t.Fatalf("Failed to add git remote: %v", err) + } + + // Call the function first time with Codespace + err = InitRepository(false, false, true) + if err != nil { + t.Fatalf("InitRepository() with Codespace returned error on first call: %v", err) + } + + // Call the function second time with Codespace + err = InitRepository(false, false, true) + if err != nil { + t.Fatalf("InitRepository() with Codespace returned error on second call: %v", err) + } + + // Verify files still exist + devcontainerPath := filepath.Join(tempDir, ".devcontainer", "devcontainer.json") + if _, err := os.Stat(devcontainerPath); os.IsNotExist(err) { + t.Errorf("Expected devcontainer.json to exist after second call") + } +} + +func TestEnsureDevcontainerCodespace_UpdatesExisting(t *testing.T) { + // Clear repository cache before test + ClearCurrentRepoSlugCache() + + // Create a temporary directory for testing + tempDir := testutil.TempDir(t, "test-*") + + // Change to temp directory + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(oldWd) + }() + err = os.Chdir(tempDir) + if err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Initialize git repo with a remote + if err := exec.Command("git", "init").Run(); err != nil { + t.Fatalf("Failed to init git repo: %v", err) + } + // Configure git + exec.Command("git", "config", "user.name", "Test User").Run() + exec.Command("git", "config", "user.email", "test@example.com").Run() + if err := exec.Command("git", "remote", "add", "origin", "https://github.com/test/example-repo.git").Run(); err != nil { + t.Fatalf("Failed to add git remote: %v", err) + } + + // Create .devcontainer directory + if err := os.MkdirAll(".devcontainer", 0755); err != nil { + t.Fatalf("Failed to create .devcontainer directory: %v", err) + } + + // Create initial devcontainer.json with existing content + initialConfig := Devcontainer{ + Image: "mcr.microsoft.com/devcontainers/go:1-bookworm", + Customizations: &DevcontainerCustomizations{ + Codespaces: &DevcontainerCodespaces{ + Repositories: map[string]DevcontainerRepositoryConfig{ + "other-org/other-repo": { + Permissions: DevcontainerRepositoryPermissions{ + Contents: "read", + }, + }, + }, + }, + }, + } + initialData, _ := json.MarshalIndent(initialConfig, "", "\t") + devcontainerPath := filepath.Join(tempDir, ".devcontainer", "devcontainer.json") + if err := os.WriteFile(devcontainerPath, initialData, 0644); err != nil { + t.Fatalf("Failed to write initial devcontainer.json: %v", err) + } + + // Call ensureDevcontainerCodespace + if err := ensureDevcontainerCodespace(false); err != nil { + t.Fatalf("ensureDevcontainerCodespace() returned error: %v", err) + } + + // Verify the config now contains both repositories + content, err := os.ReadFile(devcontainerPath) + if err != nil { + t.Fatalf("Failed to read devcontainer.json: %v", err) + } + + var config Devcontainer + if err := json.Unmarshal(content, &config); err != nil { + t.Fatalf("Failed to parse devcontainer.json: %v", err) + } + + repos := config.Customizations.Codespaces.Repositories + + // Check existing repository is preserved + if _, exists := repos["other-org/other-repo"]; !exists { + t.Errorf("Expected existing 'other-org/other-repo' to be preserved") + } + + // Check current repository was added + if _, exists := repos["test/example-repo"]; !exists { + t.Errorf("Expected 'test/example-repo' to be added") + } + + // Check gh-aw repository was added + if _, exists := repos["githubnext/gh-aw"]; !exists { + t.Errorf("Expected 'githubnext/gh-aw' to be added") + } + + // Verify original image is preserved + if config.Image != "mcr.microsoft.com/devcontainers/go:1-bookworm" { + t.Errorf("Expected original image to be preserved, got '%s'", config.Image) + } +} + +func TestEnsureDevcontainerCodespace_PreservesExistingPermissions(t *testing.T) { + // Clear repository cache before test + ClearCurrentRepoSlugCache() + + // Create a temporary directory for testing + tempDir := testutil.TempDir(t, "test-*") + + // Change to temp directory + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(oldWd) + }() + err = os.Chdir(tempDir) + if err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Initialize git repo with a remote + if err := exec.Command("git", "init").Run(); err != nil { + t.Fatalf("Failed to init git repo: %v", err) + } + // Configure git + exec.Command("git", "config", "user.name", "Test User").Run() + exec.Command("git", "config", "user.email", "test@example.com").Run() + if err := exec.Command("git", "remote", "add", "origin", "https://github.com/test/example-repo.git").Run(); err != nil { + t.Fatalf("Failed to add git remote: %v", err) + } + + // Create .devcontainer directory + if err := os.MkdirAll(".devcontainer", 0755); err != nil { + t.Fatalf("Failed to create .devcontainer directory: %v", err) + } + + // Create initial devcontainer.json with current repo having some permissions + initialConfig := Devcontainer{ + Image: "mcr.microsoft.com/devcontainers/universal:2", + Customizations: &DevcontainerCustomizations{ + Codespaces: &DevcontainerCodespaces{ + Repositories: map[string]DevcontainerRepositoryConfig{ + "test/example-repo": { + Permissions: DevcontainerRepositoryPermissions{ + Contents: "read", // Should remain read + Actions: "write", + }, + }, + }, + }, + }, + } + initialData, _ := json.MarshalIndent(initialConfig, "", "\t") + devcontainerPath := filepath.Join(tempDir, ".devcontainer", "devcontainer.json") + if err := os.WriteFile(devcontainerPath, initialData, 0644); err != nil { + t.Fatalf("Failed to write initial devcontainer.json: %v", err) + } + + // Call ensureDevcontainerCodespace + if err := ensureDevcontainerCodespace(false); err != nil { + t.Fatalf("ensureDevcontainerCodespace() returned error: %v", err) + } + + // Verify the config + content, err := os.ReadFile(devcontainerPath) + if err != nil { + t.Fatalf("Failed to read devcontainer.json: %v", err) + } + + var config Devcontainer + if err := json.Unmarshal(content, &config); err != nil { + t.Fatalf("Failed to parse devcontainer.json: %v", err) + } + + repos := config.Customizations.Codespaces.Repositories + currentRepo := repos["test/example-repo"] + + // Verify existing permissions were preserved + if currentRepo.Permissions.Contents != "read" { + t.Errorf("Expected existing contents permission 'read' to be preserved, got '%s'", currentRepo.Permissions.Contents) + } + + // Verify new permissions were added + if currentRepo.Permissions.Workflows != "write" { + t.Errorf("Expected workflows permission to be added as 'write', got '%s'", currentRepo.Permissions.Workflows) + } +} + +func TestInitRepository_CodespaceVerbose(t *testing.T) { + // Clear repository cache before test + ClearCurrentRepoSlugCache() + + // Create a temporary directory for testing + tempDir := testutil.TempDir(t, "test-*") + + // Change to temp directory + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(oldWd) + }() + err = os.Chdir(tempDir) + if err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Initialize git repo with a remote + if err := exec.Command("git", "init").Run(); err != nil { + t.Fatalf("Failed to init git repo: %v", err) + } + // Configure git + exec.Command("git", "config", "user.name", "Test User").Run() + exec.Command("git", "config", "user.email", "test@example.com").Run() + if err := exec.Command("git", "remote", "add", "origin", "https://github.com/test/example-repo.git").Run(); err != nil { + t.Fatalf("Failed to add git remote: %v", err) + } + + // Call the function with verbose=true and codespace=true (should not error) + err = InitRepository(true, false, true) + if err != nil { + t.Fatalf("InitRepository() returned error with verbose=true and codespace=true: %v", err) + } + + // Verify files were created + devcontainerPath := filepath.Join(tempDir, ".devcontainer", "devcontainer.json") + if _, err := os.Stat(devcontainerPath); os.IsNotExist(err) { + t.Errorf("Expected devcontainer.json to exist with verbose=true and codespace=true") + } +} + +func TestEnsureDevcontainerCodespace_CreatesBasicConfig(t *testing.T) { + // Clear repository cache before test + ClearCurrentRepoSlugCache() + + // Create a temporary directory for testing + tempDir := testutil.TempDir(t, "test-*") + + // Change to temp directory + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(oldWd) + }() + err = os.Chdir(tempDir) + if err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Initialize git repo with a remote + if err := exec.Command("git", "init").Run(); err != nil { + t.Fatalf("Failed to init git repo: %v", err) + } + // Configure git + exec.Command("git", "config", "user.name", "Test User").Run() + exec.Command("git", "config", "user.email", "test@example.com").Run() + if err := exec.Command("git", "remote", "add", "origin", "https://github.com/test/example-repo.git").Run(); err != nil { + t.Fatalf("Failed to add git remote: %v", err) + } + + // Call ensureDevcontainerCodespace when no devcontainer exists + if err := ensureDevcontainerCodespace(false); err != nil { + t.Fatalf("ensureDevcontainerCodespace() returned error: %v", err) + } + + // Verify the config was created with basic content + devcontainerPath := filepath.Join(tempDir, ".devcontainer", "devcontainer.json") + content, err := os.ReadFile(devcontainerPath) + if err != nil { + t.Fatalf("Failed to read devcontainer.json: %v", err) + } + + var config Devcontainer + if err := json.Unmarshal(content, &config); err != nil { + t.Fatalf("Failed to parse devcontainer.json: %v", err) + } + + // Verify basic image was set + if !strings.Contains(config.Image, "mcr.microsoft.com/devcontainers") { + t.Errorf("Expected Microsoft devcontainer image, got '%s'", config.Image) + } + + // Verify repositories were added + repos := config.Customizations.Codespaces.Repositories + if len(repos) < 2 { + t.Errorf("Expected at least 2 repositories, got %d", len(repos)) + } +} diff --git a/pkg/cli/init_command.go b/pkg/cli/init_command.go index 5e83448db1..94fa47d33e 100644 --- a/pkg/cli/init_command.go +++ b/pkg/cli/init_command.go @@ -27,6 +27,11 @@ With --mcp flag: - Creates .github/workflows/copilot-setup-steps.yml with gh-aw installation steps - Creates .vscode/mcp.json with gh-aw MCP server configuration +With --codespace flag: +- Detects or creates .devcontainer/devcontainer.json +- Adds GitHub Codespace token permissions for actions, contents, workflows, issues, pull requests, and discussions +- Adds read permissions for githubnext/gh-aw repository to enable future extension downloads + After running this command, you can: - Use GitHub Copilot Chat: type /create-agentic-workflow to create workflows interactively - Use GitHub Copilot Chat: type /setup-agentic-workflows for setup guidance @@ -37,12 +42,14 @@ After running this command, you can: Examples: ` + constants.CLIExtensionPrefix + ` init ` + constants.CLIExtensionPrefix + ` init -v - ` + constants.CLIExtensionPrefix + ` init --mcp`, + ` + constants.CLIExtensionPrefix + ` init --mcp + ` + constants.CLIExtensionPrefix + ` init --codespace`, RunE: func(cmd *cobra.Command, args []string) error { verbose, _ := cmd.Flags().GetBool("verbose") mcp, _ := cmd.Flags().GetBool("mcp") - initCommandLog.Printf("Executing init command: verbose=%v, mcp=%v", verbose, mcp) - if err := InitRepository(verbose, mcp); err != nil { + codespace, _ := cmd.Flags().GetBool("codespace") + initCommandLog.Printf("Executing init command: verbose=%v, mcp=%v, codespace=%v", verbose, mcp, codespace) + if err := InitRepository(verbose, mcp, codespace); err != nil { initCommandLog.Printf("Init command failed: %v", err) return err } @@ -52,6 +59,7 @@ Examples: } cmd.Flags().Bool("mcp", false, "Configure GitHub Copilot Agent MCP server integration") + cmd.Flags().Bool("codespace", false, "Optimize setup for GitHub Codespaces with token permissions") return cmd } diff --git a/pkg/cli/init_command_test.go b/pkg/cli/init_command_test.go index 3b8c0f6b0e..f89df098d2 100644 --- a/pkg/cli/init_command_test.go +++ b/pkg/cli/init_command_test.go @@ -88,7 +88,7 @@ func TestInitRepositoryBasic(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test basic init without MCP - err = InitRepository(false, false) + err = InitRepository(false, false, false) if err != nil { t.Fatalf("InitRepository() failed: %v", err) } @@ -136,7 +136,7 @@ func TestInitRepositoryWithMCP(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test init with MCP flag - err = InitRepository(false, true) + err = InitRepository(false, true, false) if err != nil { t.Fatalf("InitRepository() with MCP failed: %v", err) } @@ -179,7 +179,7 @@ func TestInitRepositoryVerbose(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test verbose mode (should not error, just produce more output) - err = InitRepository(true, false) + err = InitRepository(true, false, false) if err != nil { t.Fatalf("InitRepository() in verbose mode failed: %v", err) } @@ -206,7 +206,7 @@ func TestInitRepositoryNotInGitRepo(t *testing.T) { } // Don't initialize git repo - should fail for some operations - err = InitRepository(false, false) + err = InitRepository(false, false, false) // The function should handle this gracefully or return an error // Based on the implementation, ensureGitAttributes requires git @@ -240,13 +240,13 @@ func TestInitRepositoryIdempotent(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Run init twice - err = InitRepository(false, false) + err = InitRepository(false, false, false) if err != nil { t.Fatalf("First InitRepository() failed: %v", err) } // Second run should be idempotent - err = InitRepository(false, false) + err = InitRepository(false, false, false) if err != nil { t.Fatalf("Second InitRepository() failed: %v", err) } @@ -291,12 +291,12 @@ func TestInitRepositoryWithMCPIdempotent(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Run init with MCP twice - err = InitRepository(false, true) + err = InitRepository(false, true, false) if err != nil { t.Fatalf("First InitRepository() with MCP failed: %v", err) } - err = InitRepository(false, true) + err = InitRepository(false, true, false) if err != nil { t.Fatalf("Second InitRepository() with MCP failed: %v", err) } @@ -338,7 +338,7 @@ func TestInitRepositoryCreatesDirectories(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Run init with MCP - err = InitRepository(false, true) + err = InitRepository(false, true, false) if err != nil { t.Fatalf("InitRepository() failed: %v", err) } @@ -396,7 +396,7 @@ func TestInitRepositoryErrorHandling(t *testing.T) { } // Test init without git repo - err = InitRepository(false, false) + err = InitRepository(false, false, false) // Should handle error gracefully or return error // The actual behavior depends on implementation @@ -439,7 +439,7 @@ func TestInitRepositoryWithExistingFiles(t *testing.T) { } // Run init - err = InitRepository(false, false) + err = InitRepository(false, false, false) if err != nil { t.Fatalf("InitRepository() failed: %v", err) } diff --git a/pkg/cli/init_mcp_test.go b/pkg/cli/init_mcp_test.go index 1fa0f4380f..db20865968 100644 --- a/pkg/cli/init_mcp_test.go +++ b/pkg/cli/init_mcp_test.go @@ -40,7 +40,7 @@ func TestInitRepository_WithMCP(t *testing.T) { } // Call the function with MCP flag - err = InitRepository(false, true) + err = InitRepository(false, true, false) if err != nil { t.Fatalf("InitRepository() with MCP returned error: %v", err) } @@ -133,13 +133,13 @@ func TestInitRepository_MCP_Idempotent(t *testing.T) { } // Call the function first time with MCP - err = InitRepository(false, true) + err = InitRepository(false, true, false) if err != nil { t.Fatalf("InitRepository() with MCP returned error on first call: %v", err) } // Call the function second time with MCP - err = InitRepository(false, true) + err = InitRepository(false, true, false) if err != nil { t.Fatalf("InitRepository() with MCP returned error on second call: %v", err) } diff --git a/pkg/cli/init_test.go b/pkg/cli/init_test.go index edd41be6e4..d9378acb96 100644 --- a/pkg/cli/init_test.go +++ b/pkg/cli/init_test.go @@ -54,7 +54,7 @@ func TestInitRepository(t *testing.T) { } // Call the function - err = InitRepository(false, false) + err = InitRepository(false, false, false) // Check error expectation if tt.wantError { @@ -127,13 +127,13 @@ func TestInitRepository_Idempotent(t *testing.T) { } // Call the function first time - err = InitRepository(false, false) + err = InitRepository(false, false, false) if err != nil { t.Fatalf("InitRepository() returned error on first call: %v", err) } // Call the function second time - err = InitRepository(false, false) + err = InitRepository(false, false, false) if err != nil { t.Fatalf("InitRepository() returned error on second call: %v", err) } @@ -183,7 +183,7 @@ func TestInitRepository_Verbose(t *testing.T) { } // Call the function with verbose=true (should not error) - err = InitRepository(true, false) + err = InitRepository(true, false, false) if err != nil { t.Fatalf("InitRepository() returned error with verbose=true: %v", err) }