diff --git a/.github/workflows/agent-performance-analyzer.lock.yml b/.github/workflows/agent-performance-analyzer.lock.yml index 5bacbbcdf46..5292fa0757e 100644 --- a/.github/workflows/agent-performance-analyzer.lock.yml +++ b/.github/workflows/agent-performance-analyzer.lock.yml @@ -7307,7 +7307,9 @@ jobs: pull-requests: write timeout-minutes: 15 env: - GH_AW_WORKFLOW_ID: "agent" + GH_AW_ENGINE_ID: "copilot" + GH_AW_WORKFLOW_ID: "agent-performance-analyzer" + GH_AW_WORKFLOW_NAME: "Agent Performance Analyzer - Meta-Orchestrator" outputs: add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} @@ -8588,8 +8590,6 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Agent Performance Analyzer - Meta-Orchestrator" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -8888,8 +8888,6 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Agent Performance Analyzer - Meta-Orchestrator" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -9179,8 +9177,6 @@ jobs: GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} GH_AW_CREATED_DISCUSSION_URL: ${{ steps.create_discussion.outputs.discussion_url }} GH_AW_CREATED_DISCUSSION_NUMBER: ${{ steps.create_discussion.outputs.discussion_number }} - GH_AW_WORKFLOW_NAME: "Agent Performance Analyzer - Meta-Orchestrator" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/campaign-manager.lock.yml b/.github/workflows/campaign-manager.lock.yml index c77be5d4370..7d212a87191 100644 --- a/.github/workflows/campaign-manager.lock.yml +++ b/.github/workflows/campaign-manager.lock.yml @@ -7179,7 +7179,9 @@ jobs: pull-requests: write timeout-minutes: 15 env: - GH_AW_WORKFLOW_ID: "agent" + GH_AW_ENGINE_ID: "copilot" + GH_AW_WORKFLOW_ID: "campaign-manager" + GH_AW_WORKFLOW_NAME: "Campaign Manager - Meta-Orchestrator" outputs: add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} @@ -8460,8 +8462,6 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Campaign Manager - Meta-Orchestrator" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -8760,8 +8760,6 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Campaign Manager - Meta-Orchestrator" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -9051,8 +9049,6 @@ jobs: GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} GH_AW_CREATED_DISCUSSION_URL: ${{ steps.create_discussion.outputs.discussion_url }} GH_AW_CREATED_DISCUSSION_NUMBER: ${{ steps.create_discussion.outputs.discussion_number }} - GH_AW_WORKFLOW_NAME: "Campaign Manager - Meta-Orchestrator" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -9460,8 +9456,6 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Campaign Manager - Meta-Orchestrator" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/workflow-health-manager.lock.yml b/.github/workflows/workflow-health-manager.lock.yml index ae97e5a61ba..16576d52a42 100644 --- a/.github/workflows/workflow-health-manager.lock.yml +++ b/.github/workflows/workflow-health-manager.lock.yml @@ -7188,7 +7188,9 @@ jobs: pull-requests: write timeout-minutes: 15 env: - GH_AW_WORKFLOW_ID: "agent" + GH_AW_ENGINE_ID: "copilot" + GH_AW_WORKFLOW_ID: "workflow-health-manager" + GH_AW_WORKFLOW_NAME: "Workflow Health Manager - Meta-Orchestrator" outputs: add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} @@ -8612,8 +8614,6 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Workflow Health Manager - Meta-Orchestrator" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -8915,8 +8915,6 @@ jobs: GH_AW_CREATED_ISSUE_URL: ${{ steps.create_issue.outputs.issue_url }} GH_AW_CREATED_ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }} GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} - GH_AW_WORKFLOW_NAME: "Workflow Health Manager - Meta-Orchestrator" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -9324,8 +9322,6 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Workflow Health Manager - Meta-Orchestrator" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/pkg/cli/devcontainer.go b/pkg/cli/devcontainer.go index feff32b22f9..34ea1bbbe8d 100644 --- a/pkg/cli/devcontainer.go +++ b/pkg/cli/devcontainer.go @@ -46,98 +46,162 @@ type DevcontainerConfig struct { PostCreateCommand string `json:"postCreateCommand,omitempty"` } -// ensureDevcontainerConfig creates or updates .devcontainer/gh-aw/devcontainer.json +// ensureDevcontainerConfig creates or updates devcontainer.json +// If .devcontainer/devcontainer.json exists, it updates it with gh-aw configuration. +// If it doesn't exist, it creates it at the default location. func ensureDevcontainerConfig(verbose bool, additionalRepos []string) error { - devcontainerLog.Printf("Creating or updating .devcontainer/gh-aw/devcontainer.json with additional repos: %v", additionalRepos) + devcontainerLog.Printf("Creating or updating devcontainer.json with additional repos: %v", additionalRepos) - // Create .devcontainer/gh-aw directory if it doesn't exist - // Using a subdirectory to avoid overriding existing devcontainer.json files - devcontainerDir := filepath.Join(".devcontainer", "gh-aw") + // Check for existing devcontainer at default location first + defaultDevcontainerPath := filepath.Join(".devcontainer", "devcontainer.json") + devcontainerPath := defaultDevcontainerPath + + // Create .devcontainer directory if it doesn't exist + devcontainerDir := ".devcontainer" if err := os.MkdirAll(devcontainerDir, 0755); err != nil { - return fmt.Errorf("failed to create .devcontainer/gh-aw directory: %w", err) + return fmt.Errorf("failed to create .devcontainer directory: %w", err) } devcontainerLog.Printf("Ensured directory exists: %s", devcontainerDir) - devcontainerPath := filepath.Join(devcontainerDir, "devcontainer.json") - - // Check if file already exists + // Check if file already exists at default location + var existingConfig *DevcontainerConfig if _, err := os.Stat(devcontainerPath); err == nil { devcontainerLog.Printf("File already exists: %s", devcontainerPath) - // Read existing config to check if we need to update copilot-cli version + // Read existing config to update it existingData, err := os.ReadFile(devcontainerPath) if err != nil { devcontainerLog.Printf("Failed to read existing config: %v", err) - if verbose { - fmt.Fprintf(os.Stderr, "Devcontainer already exists at %s (skipping)\n", devcontainerPath) - } - return nil + return fmt.Errorf("failed to read existing devcontainer.json: %w", err) } - var existingConfig DevcontainerConfig - if err := json.Unmarshal(existingData, &existingConfig); err != nil { + var config DevcontainerConfig + if err := json.Unmarshal(existingData, &config); err != nil { devcontainerLog.Printf("Failed to parse existing config: %v", err) - if verbose { - fmt.Fprintf(os.Stderr, "Devcontainer already exists at %s (skipping)\n", devcontainerPath) - } - return nil + return fmt.Errorf("failed to parse existing devcontainer.json: %w", err) } + existingConfig = &config + devcontainerLog.Printf("Successfully parsed existing devcontainer.json") + } - // Check if copilot-cli feature exists with a different version - needsUpdate := false - if existingConfig.Features != nil { - for key := range existingConfig.Features { - // Check if this is a copilot-cli feature with a different version - if strings.HasPrefix(key, "ghcr.io/devcontainers/features/copilot-cli:") && key != "ghcr.io/devcontainers/features/copilot-cli:latest" { - needsUpdate = true - // Remove the old version - delete(existingConfig.Features, key) - devcontainerLog.Printf("Removing old copilot-cli version: %s", key) - break - } - } + // Get current repository name from git remote + repoName := getCurrentRepoName() + if repoName == "" { + repoName = "current-repo" + } + + // Get the owner from the current repository + owner := getRepoOwner() + + // Prepare gh-aw specific configuration + ghAwRepositories := buildRepositoryPermissions(repoName, owner, additionalRepos) + + var config DevcontainerConfig + + if existingConfig != nil { + // Update existing configuration + devcontainerLog.Printf("Updating existing devcontainer.json") + config = *existingConfig + + // Ensure customizations exists + if config.Customizations == nil { + config.Customizations = &DevcontainerCustomizations{} } - if needsUpdate { - // Add the latest version - if existingConfig.Features == nil { - existingConfig.Features = make(DevcontainerFeatures) + // Merge VSCode extensions + if config.Customizations.VSCode == nil { + config.Customizations.VSCode = &DevcontainerVSCode{} + } + config.Customizations.VSCode.Extensions = mergeExtensions( + config.Customizations.VSCode.Extensions, + []string{"GitHub.copilot", "GitHub.copilot-chat"}, + ) + + // Merge Codespaces repositories + if config.Customizations.Codespaces == nil { + config.Customizations.Codespaces = &DevcontainerCodespaces{ + Repositories: make(map[string]DevcontainerRepoPermissions), } - existingConfig.Features["ghcr.io/devcontainers/features/copilot-cli:latest"] = map[string]any{} - devcontainerLog.Printf("Updated copilot-cli to :latest version") + } + if config.Customizations.Codespaces.Repositories == nil { + config.Customizations.Codespaces.Repositories = make(map[string]DevcontainerRepoPermissions) + } + for repo, perms := range ghAwRepositories { + config.Customizations.Codespaces.Repositories[repo] = perms + devcontainerLog.Printf("Updated permissions for repo: %s", repo) + } - // Write updated config - updatedData, err := json.MarshalIndent(existingConfig, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal updated devcontainer.json: %w", err) + // Merge features + if config.Features == nil { + config.Features = make(DevcontainerFeatures) + } + mergeFeatures(config.Features, map[string]any{ + "ghcr.io/devcontainers/features/github-cli:1": map[string]any{}, + "ghcr.io/devcontainers/features/copilot-cli:latest": map[string]any{}, + }) + + // Update postCreateCommand if not set or if it doesn't include gh-aw install + if config.PostCreateCommand == "" || !strings.Contains(config.PostCreateCommand, "install-gh-aw.sh") { + ghAwInstall := "curl -fsSL https://raw.githubusercontent.com/githubnext/gh-aw/refs/heads/main/install-gh-aw.sh | bash" + if config.PostCreateCommand == "" { + config.PostCreateCommand = ghAwInstall + } else { + config.PostCreateCommand = config.PostCreateCommand + " && " + ghAwInstall } - updatedData = append(updatedData, '\n') + devcontainerLog.Printf("Updated postCreateCommand to include gh-aw installation") + } - if err := os.WriteFile(devcontainerPath, updatedData, 0644); err != nil { - return fmt.Errorf("failed to write updated devcontainer.json: %w", err) - } - devcontainerLog.Printf("Updated file: %s", devcontainerPath) + if verbose { + fmt.Fprintf(os.Stderr, "Updated existing devcontainer at %s\n", devcontainerPath) + } + } else { + // Create new configuration + devcontainerLog.Printf("Creating new devcontainer.json at default location") + config = DevcontainerConfig{ + Name: "Agentic Workflows Development", + Image: "mcr.microsoft.com/devcontainers/universal:latest", + Customizations: &DevcontainerCustomizations{ + VSCode: &DevcontainerVSCode{ + Extensions: []string{ + "GitHub.copilot", + "GitHub.copilot-chat", + }, + }, + Codespaces: &DevcontainerCodespaces{ + Repositories: ghAwRepositories, + }, + }, + Features: DevcontainerFeatures{ + "ghcr.io/devcontainers/features/github-cli:1": map[string]any{}, + "ghcr.io/devcontainers/features/copilot-cli:latest": map[string]any{}, + }, + PostCreateCommand: "curl -fsSL https://raw.githubusercontent.com/githubnext/gh-aw/refs/heads/main/install-gh-aw.sh | bash", + } - if verbose { - fmt.Fprintf(os.Stderr, "Updated copilot-cli to :latest in %s\n", devcontainerPath) - } - } else { - if verbose { - fmt.Fprintf(os.Stderr, "Devcontainer already exists at %s (skipping)\n", devcontainerPath) - } + if verbose { + fmt.Fprintf(os.Stderr, "Created new devcontainer at %s\n", devcontainerPath) } - return nil } - // Get current repository name from git remote - repoName := getCurrentRepoName() - if repoName == "" { - repoName = "current-repo" + // Write config file with proper indentation + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal devcontainer.json: %w", err) } - // Get the owner from the current repository - owner := getRepoOwner() + // Add newline at end of file + data = append(data, '\n') + + if err := os.WriteFile(devcontainerPath, data, 0644); err != nil { + return fmt.Errorf("failed to write devcontainer.json: %w", err) + } + devcontainerLog.Printf("Wrote file: %s", devcontainerPath) + + return nil +} +// buildRepositoryPermissions creates the repository permissions map for gh-aw +func buildRepositoryPermissions(repoName, owner string, additionalRepos []string) map[string]DevcontainerRepoPermissions { // Create repository permissions map // Reference: https://docs.github.com/en/codespaces/managing-your-codespaces/managing-repository-access-for-your-codespaces // Default codespace permissions are read/write to the repository from which it was created. @@ -176,7 +240,9 @@ func ensureDevcontainerConfig(verbose bool, additionalRepos []string) error { if len(parts) >= 2 { repoOwner := parts[0] if owner != "" && repoOwner != owner { - return fmt.Errorf("repository '%s' is not in the same organization as the current repository (expected owner: '%s')", repo, owner) + // Skip repos with different owners rather than error + devcontainerLog.Printf("Skipping repository '%s' - different owner than current repo (expected: '%s')", repo, owner) + continue } } } else if owner != "" { @@ -198,43 +264,48 @@ func ensureDevcontainerConfig(verbose bool, additionalRepos []string) error { } } - // Create devcontainer configuration - config := DevcontainerConfig{ - Name: "Agentic Workflows Development", - Image: "mcr.microsoft.com/devcontainers/universal:latest", - Customizations: &DevcontainerCustomizations{ - VSCode: &DevcontainerVSCode{ - Extensions: []string{ - "GitHub.copilot", - "GitHub.copilot-chat", - }, - }, - Codespaces: &DevcontainerCodespaces{ - Repositories: repositories, - }, - }, - Features: DevcontainerFeatures{ - "ghcr.io/devcontainers/features/github-cli:1": map[string]any{}, - "ghcr.io/devcontainers/features/copilot-cli:latest": map[string]any{}, - }, - PostCreateCommand: "curl -fsSL https://raw.githubusercontent.com/githubnext/gh-aw/refs/heads/main/install-gh-aw.sh | bash", + return repositories +} + +// mergeExtensions adds new extensions to existing list, avoiding duplicates +func mergeExtensions(existing, toAdd []string) []string { + extensionSet := make(map[string]bool) + result := make([]string, 0, len(existing)+len(toAdd)) + + // Add existing extensions + for _, ext := range existing { + if !extensionSet[ext] { + extensionSet[ext] = true + result = append(result, ext) + } } - // Write config file with proper indentation - data, err := json.MarshalIndent(config, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal devcontainer.json: %w", err) + // Add new extensions if not already present + for _, ext := range toAdd { + if !extensionSet[ext] { + extensionSet[ext] = true + result = append(result, ext) + } } - // Add newline at end of file - data = append(data, '\n') + return result +} - if err := os.WriteFile(devcontainerPath, data, 0644); err != nil { - return fmt.Errorf("failed to write devcontainer.json: %w", err) +// mergeFeatures adds new features to existing features map, updating old copilot-cli versions +func mergeFeatures(existing DevcontainerFeatures, toAdd map[string]any) { + // First, remove old copilot-cli versions + for key := range existing { + if strings.HasPrefix(key, "ghcr.io/devcontainers/features/copilot-cli:") && + key != "ghcr.io/devcontainers/features/copilot-cli:latest" { + delete(existing, key) + devcontainerLog.Printf("Removed old copilot-cli version: %s", key) + } } - devcontainerLog.Printf("Created file: %s", devcontainerPath) - return nil + // Add new features + for key, value := range toAdd { + existing[key] = value + } } // getCurrentRepoName gets the current repository name from git remote in owner/repo format diff --git a/pkg/cli/devcontainer_test.go b/pkg/cli/devcontainer_test.go index 02ec7420414..a1b6c51e69e 100644 --- a/pkg/cli/devcontainer_test.go +++ b/pkg/cli/devcontainer_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/githubnext/gh-aw/pkg/testutil" @@ -40,8 +41,8 @@ func TestEnsureDevcontainerConfig(t *testing.T) { t.Fatalf("ensureDevcontainerConfig() failed: %v", err) } - // Verify .devcontainer/devcontainer.json was created - devcontainerPath := filepath.Join(".devcontainer", "gh-aw", "devcontainer.json") + // Verify .devcontainer/devcontainer.json was created at default location + devcontainerPath := filepath.Join(".devcontainer", "devcontainer.json") if _, err := os.Stat(devcontainerPath); os.IsNotExist(err) { t.Fatal("Expected .devcontainer/devcontainer.json to be created") } @@ -153,8 +154,8 @@ func TestEnsureDevcontainerConfigWithAdditionalRepos(t *testing.T) { t.Fatalf("ensureDevcontainerConfig() failed: %v", err) } - // Read and parse the created file - devcontainerPath := filepath.Join(".devcontainer", "gh-aw", "devcontainer.json") + // Read and parse the created file at default location + devcontainerPath := filepath.Join(".devcontainer", "devcontainer.json") data, err := os.ReadFile(devcontainerPath) if err != nil { t.Fatalf("Failed to read devcontainer.json: %v", err) @@ -220,8 +221,8 @@ func TestEnsureDevcontainerConfigWithCurrentRepo(t *testing.T) { t.Fatalf("ensureDevcontainerConfig() failed: %v", err) } - // Read and parse the created file - devcontainerPath := filepath.Join(".devcontainer", "gh-aw", "devcontainer.json") + // Read and parse the created file at default location + devcontainerPath := filepath.Join(".devcontainer", "devcontainer.json") data, err := os.ReadFile(devcontainerPath) if err != nil { t.Fatalf("Failed to read devcontainer.json: %v", err) @@ -284,18 +285,44 @@ func TestEnsureDevcontainerConfigWithOwnerValidation(t *testing.T) { t.Fatalf("ensureDevcontainerConfig() with same owner should succeed: %v", err) } + // Verify the repo was added + devcontainerPath := filepath.Join(".devcontainer", "devcontainer.json") + data, err := os.ReadFile(devcontainerPath) + if err != nil { + t.Fatalf("Failed to read devcontainer.json: %v", err) + } + + var config DevcontainerConfig + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("Failed to parse devcontainer.json: %v", err) + } + + if _, exists := config.Customizations.Codespaces.Repositories["testowner/repo1"]; !exists { + t.Error("Expected testowner/repo1 to be in repositories") + } + // Clean up for next test - os.RemoveAll(filepath.Join(".devcontainer", "gh-aw")) + os.RemoveAll(filepath.Join(".devcontainer", "devcontainer.json")) - // Test that different owner fails + // Test that different owner is skipped (not an error, just logged and skipped) err = ensureDevcontainerConfig(false, []string{"differentowner/repo2"}) - if err == nil { - t.Fatal("ensureDevcontainerConfig() with different owner should fail") + if err != nil { + t.Fatalf("ensureDevcontainerConfig() with different owner should succeed but skip the repo: %v", err) } - // Check that error message contains expected text - if err.Error() == "" || len(err.Error()) < 10 { - t.Errorf("Expected meaningful error message, got: %v", err) + // Verify the file was created but without the different-owner repo + data, err = os.ReadFile(devcontainerPath) + if err != nil { + t.Fatalf("Failed to read devcontainer.json: %v", err) + } + + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("Failed to parse devcontainer.json: %v", err) + } + + // Different owner repo should not be in the config + if _, exists := config.Customizations.Codespaces.Repositories["differentowner/repo2"]; exists { + t.Error("Expected differentowner/repo2 to be skipped") } } @@ -323,21 +350,21 @@ func TestEnsureDevcontainerConfigUpdatesOldVersion(t *testing.T) { exec.Command("git", "config", "user.name", "Test User").Run() exec.Command("git", "config", "user.email", "test@example.com").Run() - // Create .devcontainer/gh-aw directory - devcontainerDir := filepath.Join(".devcontainer", "gh-aw") + // Create .devcontainer directory + devcontainerDir := ".devcontainer" if err := os.MkdirAll(devcontainerDir, 0755); err != nil { t.Fatalf("Failed to create directory: %v", err) } - // Create a devcontainer.json with old copilot-cli version + // Create a devcontainer.json with old copilot-cli version at default location oldConfig := DevcontainerConfig{ - Name: "Agentic Workflows Development", - Image: "mcr.microsoft.com/devcontainers/universal:latest", + Name: "Existing Dev Environment", + Image: "mcr.microsoft.com/devcontainers/go:latest", Features: DevcontainerFeatures{ "ghcr.io/devcontainers/features/github-cli:1": map[string]any{}, "ghcr.io/devcontainers/features/copilot-cli:1": map[string]any{}, // Old version }, - PostCreateCommand: "curl -fsSL https://raw.githubusercontent.com/githubnext/gh-aw/refs/heads/main/install-gh-aw.sh | bash", + PostCreateCommand: "echo 'existing setup'", } devcontainerPath := filepath.Join(devcontainerDir, "devcontainer.json") @@ -377,6 +404,188 @@ func TestEnsureDevcontainerConfigUpdatesOldVersion(t *testing.T) { if _, exists := updatedConfig.Features["ghcr.io/devcontainers/features/copilot-cli:1"]; exists { t.Error("Expected old copilot-cli:1 version to be removed") } + + // Verify existing config properties were preserved + if updatedConfig.Name != "Existing Dev Environment" { + t.Errorf("Expected name to be preserved, got %q", updatedConfig.Name) + } + + if updatedConfig.Image != "mcr.microsoft.com/devcontainers/go:latest" { + t.Errorf("Expected image to be preserved, got %q", updatedConfig.Image) + } + + // Verify postCreateCommand was updated to include gh-aw + if !strings.Contains(updatedConfig.PostCreateCommand, "install-gh-aw.sh") { + t.Error("Expected postCreateCommand to include gh-aw installation") + } + if !strings.Contains(updatedConfig.PostCreateCommand, "echo 'existing setup'") { + t.Error("Expected postCreateCommand to preserve existing command") + } + + // Verify GitHub Copilot extensions were added + hasGitHubCopilot := false + hasCopilotChat := false + for _, ext := range updatedConfig.Customizations.VSCode.Extensions { + if ext == "GitHub.copilot" { + hasGitHubCopilot = true + } + if ext == "GitHub.copilot-chat" { + hasCopilotChat = true + } + } + if !hasGitHubCopilot { + t.Error("Expected GitHub.copilot extension to be added") + } + if !hasCopilotChat { + t.Error("Expected GitHub.copilot-chat extension to be added") + } +} + +func TestEnsureDevcontainerConfigMergesWithExisting(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(originalDir) + }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Initialize git repo + if err := exec.Command("git", "init").Run(); err != nil { + t.Skip("Git not available") + } + + // Configure git and add remote + exec.Command("git", "config", "user.name", "Test User").Run() + exec.Command("git", "config", "user.email", "test@example.com").Run() + exec.Command("git", "remote", "add", "origin", "https://github.com/testorg/testrepo.git").Run() + + // Create .devcontainer directory + devcontainerDir := ".devcontainer" + if err := os.MkdirAll(devcontainerDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + // Create an existing devcontainer.json with custom configuration + existingConfig := DevcontainerConfig{ + Name: "My Custom Dev Environment", + Image: "mcr.microsoft.com/devcontainers/python:3.11", + Customizations: &DevcontainerCustomizations{ + VSCode: &DevcontainerVSCode{ + Extensions: []string{ + "ms-python.python", + "ms-python.vscode-pylance", + }, + }, + }, + Features: DevcontainerFeatures{ + "ghcr.io/devcontainers/features/docker-in-docker:2": map[string]any{}, + }, + PostCreateCommand: "pip install -r requirements.txt", + } + + devcontainerPath := filepath.Join(devcontainerDir, "devcontainer.json") + data, err := json.MarshalIndent(existingConfig, "", " ") + if err != nil { + t.Fatalf("Failed to marshal existing config: %v", err) + } + data = append(data, '\n') + + if err := os.WriteFile(devcontainerPath, data, 0644); err != nil { + t.Fatalf("Failed to write existing config: %v", err) + } + + // Run ensureDevcontainerConfig - should merge with existing config + err = ensureDevcontainerConfig(false, []string{}) + if err != nil { + t.Fatalf("ensureDevcontainerConfig() failed: %v", err) + } + + // Read and verify the merged config + mergedData, err := os.ReadFile(devcontainerPath) + if err != nil { + t.Fatalf("Failed to read merged config: %v", err) + } + + var mergedConfig DevcontainerConfig + if err := json.Unmarshal(mergedData, &mergedConfig); err != nil { + t.Fatalf("Failed to parse merged config: %v", err) + } + + // Verify existing properties were preserved + if mergedConfig.Name != "My Custom Dev Environment" { + t.Errorf("Expected name to be preserved, got %q", mergedConfig.Name) + } + + if mergedConfig.Image != "mcr.microsoft.com/devcontainers/python:3.11" { + t.Errorf("Expected image to be preserved, got %q", mergedConfig.Image) + } + + // Verify existing extensions were preserved and new ones added + extensions := mergedConfig.Customizations.VSCode.Extensions + hasPython := false + hasPylance := false + hasGitHubCopilot := false + hasCopilotChat := false + + for _, ext := range extensions { + switch ext { + case "ms-python.python": + hasPython = true + case "ms-python.vscode-pylance": + hasPylance = true + case "GitHub.copilot": + hasGitHubCopilot = true + case "GitHub.copilot-chat": + hasCopilotChat = true + } + } + + if !hasPython { + t.Error("Expected existing ms-python.python extension to be preserved") + } + if !hasPylance { + t.Error("Expected existing ms-python.vscode-pylance extension to be preserved") + } + if !hasGitHubCopilot { + t.Error("Expected GitHub.copilot extension to be added") + } + if !hasCopilotChat { + t.Error("Expected GitHub.copilot-chat extension to be added") + } + + // Verify existing features were preserved and new ones added + if _, exists := mergedConfig.Features["ghcr.io/devcontainers/features/docker-in-docker:2"]; !exists { + t.Error("Expected existing docker-in-docker feature to be preserved") + } + if _, exists := mergedConfig.Features["ghcr.io/devcontainers/features/github-cli:1"]; !exists { + t.Error("Expected github-cli feature to be added") + } + if _, exists := mergedConfig.Features["ghcr.io/devcontainers/features/copilot-cli:latest"]; !exists { + t.Error("Expected copilot-cli feature to be added") + } + + // Verify postCreateCommand was updated to include gh-aw + if !strings.Contains(mergedConfig.PostCreateCommand, "pip install -r requirements.txt") { + t.Error("Expected postCreateCommand to preserve existing command") + } + if !strings.Contains(mergedConfig.PostCreateCommand, "install-gh-aw.sh") { + t.Error("Expected postCreateCommand to include gh-aw installation") + } + + // Verify codespaces repository permissions were added + if mergedConfig.Customizations.Codespaces == nil { + t.Fatal("Expected Codespaces configuration to be added") + } + if _, exists := mergedConfig.Customizations.Codespaces.Repositories["testorg/testrepo"]; !exists { + t.Error("Expected testorg/testrepo to be in repositories") + } } func TestGetCurrentRepoName(t *testing.T) { diff --git a/pkg/cli/init.go b/pkg/cli/init.go index 2c3fa8108d6..23f34d2c622 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -118,13 +118,13 @@ func InitRepository(verbose bool, mcp bool, campaign bool, tokens bool, engine s if codespaceEnabled { initLog.Printf("Configuring GitHub Codespaces devcontainer with additional repos: %v", codespaceRepos) - // Create .devcontainer/gh-aw/devcontainer.json + // Create or update .devcontainer/devcontainer.json if err := ensureDevcontainerConfig(verbose, codespaceRepos); err != nil { - initLog.Printf("Failed to create devcontainer config: %v", err) - return fmt.Errorf("failed to create devcontainer config: %w", err) + initLog.Printf("Failed to configure devcontainer: %v", err) + return fmt.Errorf("failed to configure devcontainer: %w", err) } if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Created .devcontainer/gh-aw/devcontainer.json")) + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Configured .devcontainer/devcontainer.json")) } } diff --git a/pkg/cli/init_command.go b/pkg/cli/init_command.go index f1304cf8a1b..b261f1a0ae2 100644 --- a/pkg/cli/init_command.go +++ b/pkg/cli/init_command.go @@ -39,11 +39,10 @@ With --tokens flag: - Use with --engine flag to check engine-specific tokens (copilot, claude, codex) With --codespaces flag: -- Creates .devcontainer/gh-aw/devcontainer.json with universal image (in subfolder to avoid conflicts) +- Updates existing .devcontainer/devcontainer.json if present, otherwise creates new file at default location - Configures permissions for current repo: actions:write, contents:write, discussions:read, issues:read, pull-requests:write, workflows:write - Configures permissions for additional repos (in same org): actions:read, contents:read, discussions:read, issues:read, pull-requests:read, workflows:read -- Pre-installs gh aw extension CLI -- Pre-installs @github/copilot +- Adds GitHub Copilot extensions and gh aw CLI installation - Use without value (--codespaces) for current repo only, or with comma-separated repos (--codespaces repo1,repo2) With --campaign flag: diff --git a/pkg/cli/init_command_test.go b/pkg/cli/init_command_test.go index ff67500288b..dc6ff963c72 100644 --- a/pkg/cli/init_command_test.go +++ b/pkg/cli/init_command_test.go @@ -656,8 +656,8 @@ func TestInitRepositoryWithCodespace(t *testing.T) { t.Fatalf("InitRepository() with codespaces failed: %v", err) } - // Verify .devcontainer/devcontainer.json was created - devcontainerPath := filepath.Join(".devcontainer", "gh-aw", "devcontainer.json") + // Verify .devcontainer/devcontainer.json was created at default location + devcontainerPath := filepath.Join(".devcontainer", "devcontainer.json") if _, err := os.Stat(devcontainerPath); os.IsNotExist(err) { t.Error("Expected .devcontainer/devcontainer.json to be created") } @@ -721,10 +721,10 @@ func TestInitCommandWithCodespacesNoArgs(t *testing.T) { t.Fatalf("InitRepository() with codespaces (no args) failed: %v", err) } - // Verify .devcontainer/gh-aw/devcontainer.json was created - devcontainerPath := filepath.Join(".devcontainer", "gh-aw", "devcontainer.json") + // Verify .devcontainer/devcontainer.json was created at default location + devcontainerPath := filepath.Join(".devcontainer", "devcontainer.json") if _, err := os.Stat(devcontainerPath); os.IsNotExist(err) { - t.Error("Expected .devcontainer/gh-aw/devcontainer.json to be created") + t.Error("Expected .devcontainer/devcontainer.json to be created") } // Verify only current repo is configured diff --git a/pkg/workflow/compiler_safe_outputs_consolidated.go b/pkg/workflow/compiler_safe_outputs_consolidated.go index c76a487e253..546cd4d4984 100644 --- a/pkg/workflow/compiler_safe_outputs_consolidated.go +++ b/pkg/workflow/compiler_safe_outputs_consolidated.go @@ -594,7 +594,7 @@ func (c *Compiler) buildJobLevelSafeOutputEnvVars(data *WorkflowData, workflowID // Add workflow metadata that's common to all steps envVars["GH_AW_WORKFLOW_NAME"] = fmt.Sprintf("%q", data.Name) - + if data.Source != "" { envVars["GH_AW_WORKFLOW_SOURCE"] = fmt.Sprintf("%q", data.Source) sourceURL := buildSourceURL(data.Source)