diff --git a/README.md b/README.md index d99e225..9696e82 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,7 @@ gh devlake configure scope --org my-org --repos my-org/app1 \ | Flag | Default | Description | |------|---------|-------------| | `--org` | | GitHub organization slug | +| `--plugin` | *(interactive)* | Plugin to configure (`github`, `gh-copilot`) | | `--repos` | | Comma-separated repos (`owner/repo`) | | `--repos-file` | | Path to file with repos (one per line) | | `--project-name` | *(org name)* | DevLake project name | @@ -263,20 +264,21 @@ gh devlake configure scope --org my-org --repos my-org/app1 \ | `--incident-label` | `incident` | Issue label identifying incidents | | `--cron` | `0 0 * * *` | Blueprint sync schedule (daily midnight) | | `--time-after` | *(6 months ago)* | Only collect data after this date | -| `--skip-copilot` | `false` | Skip adding Copilot org scope | | `--skip-sync` | `false` | Skip triggering the first data sync | | `--wait` | `true` | Wait for pipeline to complete | | `--timeout` | `5m` | Max time to wait for pipeline | | `--github-connection-id` | *(auto)* | Override auto-detected GitHub connection ID | | `--copilot-connection-id` | *(auto)* | Override auto-detected Copilot connection ID | +> **Note:** `--skip-copilot` and `--skip-github` are deprecated — use `--plugin github` or `--plugin gh-copilot` instead. + **What it does:** 1. Discovers DevLake and resolves connection IDs (from state file or API) 2. Resolves repos (from flag, file, or interactive `gh repo list` selection) 3. Looks up repo details via `gh api repos//` 4. Creates a DORA scope config (deployment/production patterns, incident label) 5. Adds repo scopes to the GitHub connection -6. Adds Copilot org scope (unless `--skip-copilot`) +6. Adds Copilot org scope (unless `--plugin github`) 7. Creates a DevLake project with DORA metrics enabled 8. Configures the project's blueprint with connection scopes 9. Triggers the first data sync and monitors the pipeline @@ -291,9 +293,10 @@ Combine connections + scopes configuration in one step (Phase 2 + Phase 3). gh devlake configure full --org my-org --repos my-org/app1,my-org/app2 gh devlake configure full --org my-org --repos-file repos.txt gh devlake configure full --org my-org --enterprise my-ent --skip-sync +gh devlake configure full --org my-org --plugin github ``` -Accepts all flags from both `configure connection` and `configure scope`. Runs Phase 2 first, then Phase 3 — wiring connection IDs automatically between the two phases. +Accepts all flags from both `configure connection` and `configure scope`. Use `--plugin` to limit the run to a single plugin (skips the interactive picker). Runs Phase 2 first, then Phase 3 — wiring connection IDs automatically between the two phases. --- diff --git a/cmd/configure_full.go b/cmd/configure_full.go index 74e2626..06c1d3e 100644 --- a/cmd/configure_full.go +++ b/cmd/configure_full.go @@ -39,6 +39,7 @@ func init() { configureFullCmd.Flags().StringVar(&fullToken, "token", "", "GitHub PAT") configureFullCmd.Flags().StringVar(&fullEnvFile, "env-file", ".devlake.env", "Path to env file containing GITHUB_PAT") configureFullCmd.Flags().BoolVar(&fullSkipClean, "skip-cleanup", false, "Do not delete .devlake.env after setup") + configureFullCmd.Flags().StringVar(&scopePlugin, "plugin", "", "Limit to one plugin (github, gh-copilot)") // Scope flags (reuse the package-level vars from configure_scopes.go) configureFullCmd.Flags().StringVar(&scopeRepos, "repos", "", "Comma-separated repos (owner/repo)") @@ -50,7 +51,10 @@ func init() { configureFullCmd.Flags().StringVar(&scopeTimeAfter, "time-after", "", "Only collect data after this date") configureFullCmd.Flags().StringVar(&scopeCron, "cron", "0 0 * * *", "Blueprint cron schedule") configureFullCmd.Flags().BoolVar(&scopeSkipSync, "skip-sync", false, "Skip first data sync") - configureFullCmd.Flags().BoolVar(&scopeSkipCopilot, "skip-copilot", false, "Skip Copilot scope") + configureFullCmd.Flags().BoolVar(&scopeSkipCopilot, "skip-copilot", false, "Deprecated: use --plugin github instead") + configureFullCmd.Flags().BoolVar(&scopeSkipGitHub, "skip-github", false, "Deprecated: use --plugin gh-copilot instead") + _ = configureFullCmd.Flags().MarkHidden("skip-copilot") + _ = configureFullCmd.Flags().MarkHidden("skip-github") } func runConfigureFull(cmd *cobra.Command, args []string) error { @@ -61,19 +65,33 @@ func runConfigureFull(cmd *cobra.Command, args []string) error { // ── Select connections ── available := AvailableConnections() - var labels []string - for _, d := range available { - labels = append(labels, d.DisplayName) - } - selectedLabels := prompt.SelectMultiWithDefaults("Which connections to configure?", labels, []int{1, 2}) var defs []*ConnectionDef - for _, label := range selectedLabels { + if scopePlugin != "" { + // --plugin limits to one plugin: skip the interactive picker for _, d := range available { - if d.DisplayName == label { + if d.Plugin == scopePlugin { defs = append(defs, d) break } } + if len(defs) == 0 { + return fmt.Errorf("unknown plugin %q — choose: github, gh-copilot", scopePlugin) + } + } else { + var labels []string + for _, d := range available { + labels = append(labels, d.DisplayName) + } + fmt.Println() + selectedLabels := prompt.SelectMultiWithDefaults("Which connections to configure?", labels, []int{1, 2}) + for _, label := range selectedLabels { + for _, d := range available { + if d.DisplayName == label { + defs = append(defs, d) + break + } + } + } } if len(defs) == 0 { return fmt.Errorf("at least one connection is required") diff --git a/cmd/configure_projects.go b/cmd/configure_projects.go index 3da01a6..da2280b 100644 --- a/cmd/configure_projects.go +++ b/cmd/configure_projects.go @@ -36,6 +36,7 @@ Example: cmd.Flags().StringVar(&scopeOrg, "org", "", "GitHub organization slug") cmd.Flags().StringVar(&scopeEnterprise, "enterprise", "", "GitHub enterprise slug (enables enterprise-level Copilot metrics)") + cmd.Flags().StringVar(&scopePlugin, "plugin", "", "Plugin to configure (github, gh-copilot)") cmd.Flags().StringVar(&scopeRepos, "repos", "", "Comma-separated repos (owner/repo)") cmd.Flags().StringVar(&scopeReposFile, "repos-file", "", "Path to file with repos (one per line)") cmd.Flags().IntVar(&scopeGHConnID, "github-connection-id", 0, "GitHub connection ID (auto-detected if omitted)") @@ -47,9 +48,12 @@ Example: cmd.Flags().StringVar(&scopeTimeAfter, "time-after", "", "Only collect data after this date (default: 6 months ago)") cmd.Flags().StringVar(&scopeCron, "cron", "0 0 * * *", "Blueprint cron schedule") cmd.Flags().BoolVar(&scopeSkipSync, "skip-sync", false, "Skip triggering the first data sync") - cmd.Flags().BoolVar(&scopeSkipCopilot, "skip-copilot", false, "Skip adding Copilot scope") + cmd.Flags().BoolVar(&scopeSkipCopilot, "skip-copilot", false, "Deprecated: use --plugin github instead") + cmd.Flags().BoolVar(&scopeSkipGitHub, "skip-github", false, "Deprecated: use --plugin gh-copilot instead") cmd.Flags().BoolVar(&scopeWait, "wait", true, "Wait for pipeline to complete") cmd.Flags().DurationVar(&scopeTimeout, "timeout", 5*time.Minute, "Max time to wait for pipeline") + _ = cmd.Flags().MarkHidden("skip-copilot") + _ = cmd.Flags().MarkHidden("skip-github") return cmd } @@ -126,6 +130,15 @@ func runConfigureProjects(cmd *cobra.Command, args []string) error { // ── Discover connections ── fmt.Println("\n🔍 Discovering connections...") choices := discoverConnections(client, state) + if scopePlugin != "" { + switch scopePlugin { + case "github", "gh-copilot": + // valid + default: + return fmt.Errorf("unknown plugin %q — choose: github, gh-copilot", scopePlugin) + } + choices = filterChoicesByPlugin(choices, scopePlugin) + } if len(choices) == 0 { return fmt.Errorf("no connections found — run 'gh devlake configure connection' first") } @@ -346,6 +359,17 @@ func discoverConnections(client *devlake.Client, state *devlake.State) []connCho return choices } +// filterChoicesByPlugin returns only the connections matching the given plugin slug. +func filterChoicesByPlugin(choices []connChoice, plugin string) []connChoice { + var out []connChoice + for _, c := range choices { + if c.plugin == plugin { + out = append(out, c) + } + } + return out +} + // pluginDisplayName returns a friendly name for a plugin slug. func pluginDisplayName(plugin string) string { switch plugin { diff --git a/cmd/configure_scopes.go b/cmd/configure_scopes.go index 7386a6c..3127c0d 100644 --- a/cmd/configure_scopes.go +++ b/cmd/configure_scopes.go @@ -18,6 +18,7 @@ import ( var ( scopeOrg string scopeEnterprise string + scopePlugin string scopeRepos string scopeReposFile string scopeGHConnID int @@ -52,6 +53,7 @@ Example: cmd.Flags().StringVar(&scopeOrg, "org", "", "GitHub organization slug") cmd.Flags().StringVar(&scopeEnterprise, "enterprise", "", "GitHub enterprise slug (enables enterprise-level Copilot metrics)") + cmd.Flags().StringVar(&scopePlugin, "plugin", "", "Plugin to configure (github, gh-copilot)") cmd.Flags().StringVar(&scopeRepos, "repos", "", "Comma-separated repos (owner/repo)") cmd.Flags().StringVar(&scopeReposFile, "repos-file", "", "Path to file with repos (one per line)") cmd.Flags().IntVar(&scopeGHConnID, "github-connection-id", 0, "GitHub connection ID (auto-detected if omitted)") @@ -63,9 +65,12 @@ Example: cmd.Flags().StringVar(&scopeTimeAfter, "time-after", "", "Only collect data after this date (default: 6 months ago)") cmd.Flags().StringVar(&scopeCron, "cron", "0 0 * * *", "Blueprint cron schedule") cmd.Flags().BoolVar(&scopeSkipSync, "skip-sync", false, "Skip triggering the first data sync") - cmd.Flags().BoolVar(&scopeSkipCopilot, "skip-copilot", false, "Skip adding Copilot scope") + cmd.Flags().BoolVar(&scopeSkipCopilot, "skip-copilot", false, "Deprecated: use --plugin github instead") + cmd.Flags().BoolVar(&scopeSkipGitHub, "skip-github", false, "Deprecated: use --plugin gh-copilot instead") cmd.Flags().BoolVar(&scopeWait, "wait", true, "Wait for pipeline to complete") cmd.Flags().DurationVar(&scopeTimeout, "timeout", 5*time.Minute, "Max time to wait for pipeline") + _ = cmd.Flags().MarkHidden("skip-copilot") + _ = cmd.Flags().MarkHidden("skip-github") return cmd } @@ -253,6 +258,46 @@ func finalizeProject(opts finalizeProjectOpts) error { func runConfigureScopes(cmd *cobra.Command, args []string) error { fmt.Println() + // ── Resolve --plugin flag (replaces --skip-copilot / --skip-github) ── + if scopePlugin != "" { + switch scopePlugin { + case "github": + scopeSkipCopilot = true + scopeSkipGitHub = false + case "gh-copilot": + scopeSkipGitHub = true + scopeSkipCopilot = false + default: + return fmt.Errorf("unknown plugin %q — choose: github, gh-copilot", scopePlugin) + } + } else if !cmd.Flags().Changed("skip-copilot") && !cmd.Flags().Changed("skip-github") { + // No --plugin and no deprecated skip flags: determine mode + flagMode := cmd.Flags().Changed("org") || + cmd.Flags().Changed("repos") || + cmd.Flags().Changed("repos-file") || + cmd.Flags().Changed("github-connection-id") || + cmd.Flags().Changed("copilot-connection-id") + if flagMode { + return fmt.Errorf("--plugin is required when using flags (use --plugin github or --plugin gh-copilot)") + } + // Interactive mode: prompt for plugin + available := AvailableConnections() + var labels []string + for _, d := range available { + labels = append(labels, d.DisplayName) + } + fmt.Println() + chosen := prompt.Select("Which plugin to configure?", labels) + switch chosen { + case "GitHub": + scopeSkipCopilot = true + case "GitHub Copilot": + scopeSkipGitHub = true + default: + return fmt.Errorf("plugin selection is required") + } + } + // ── Step 1: Discover DevLake ── fmt.Println("🔍 Discovering DevLake instance...") disc, err := devlake.Discover(cfgURL) diff --git a/cmd/configure_scopes_test.go b/cmd/configure_scopes_test.go index c51b23b..245afb4 100644 --- a/cmd/configure_scopes_test.go +++ b/cmd/configure_scopes_test.go @@ -1,6 +1,10 @@ package cmd -import "testing" +import ( + "testing" + + "github.com/spf13/cobra" +) func TestCopilotScopeID(t *testing.T) { tests := []struct { @@ -57,3 +61,121 @@ func TestCopilotScopeID(t *testing.T) { }) } } + +func TestRunConfigureScopes_PluginFlag(t *testing.T) { + // Build a minimal cobra command that mirrors the real flag set so we can + // call runConfigureScopes with controlled flag state. + makeCmd := func() *cobra.Command { + cmd := &cobra.Command{RunE: runConfigureScopes} + cmd.Flags().StringVar(&scopePlugin, "plugin", "", "") + cmd.Flags().StringVar(&scopeOrg, "org", "", "") + cmd.Flags().StringVar(&scopeRepos, "repos", "", "") + cmd.Flags().StringVar(&scopeReposFile, "repos-file", "", "") + cmd.Flags().IntVar(&scopeGHConnID, "github-connection-id", 0, "") + cmd.Flags().IntVar(&scopeCopilotConnID, "copilot-connection-id", 0, "") + cmd.Flags().BoolVar(&scopeSkipCopilot, "skip-copilot", false, "") + cmd.Flags().BoolVar(&scopeSkipGitHub, "skip-github", false, "") + return cmd + } + + t.Run("unknown plugin returns error", func(t *testing.T) { + scopePlugin = "gitlab" + scopeSkipCopilot = false + scopeSkipGitHub = false + cmd := makeCmd() + _ = cmd.Flags().Set("plugin", "gitlab") + err := runConfigureScopes(cmd, nil) + if err == nil || err.Error() != `unknown plugin "gitlab" — choose: github, gh-copilot` { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("flag mode without --plugin returns error", func(t *testing.T) { + scopePlugin = "" + scopeSkipCopilot = false + scopeSkipGitHub = false + cmd := makeCmd() + _ = cmd.Flags().Set("org", "my-org") + err := runConfigureScopes(cmd, nil) + if err == nil || err.Error() != "--plugin is required when using flags (use --plugin github or --plugin gh-copilot)" { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("--plugin github sets skip-copilot", func(t *testing.T) { + // We can't fully run without a DevLake instance; just verify the + // plugin resolution code runs without immediate error beyond DevLake discovery. + // The error we expect is from devlake.Discover, not from plugin resolution. + scopePlugin = "github" + scopeSkipCopilot = false + scopeSkipGitHub = false + cmd := makeCmd() + _ = cmd.Flags().Set("plugin", "github") + _ = cmd.Flags().Set("org", "my-org") + // runConfigureScopes will fail at DevLake discovery, but skip flags should be set + runConfigureScopes(cmd, nil) //nolint:errcheck + if !scopeSkipCopilot { + t.Error("expected scopeSkipCopilot=true after --plugin github") + } + if scopeSkipGitHub { + t.Error("expected scopeSkipGitHub=false after --plugin github") + } + }) + + t.Run("--plugin gh-copilot sets skip-github", func(t *testing.T) { + scopePlugin = "gh-copilot" + scopeSkipCopilot = false + scopeSkipGitHub = false + cmd := makeCmd() + _ = cmd.Flags().Set("plugin", "gh-copilot") + _ = cmd.Flags().Set("org", "my-org") + runConfigureScopes(cmd, nil) //nolint:errcheck + if scopeSkipCopilot { + t.Error("expected scopeSkipCopilot=false after --plugin gh-copilot") + } + if !scopeSkipGitHub { + t.Error("expected scopeSkipGitHub=true after --plugin gh-copilot") + } + }) +} + +func TestFilterChoicesByPlugin(t *testing.T) { + choices := []connChoice{ + {plugin: "github", id: 1, label: "GitHub (ID: 1)"}, + {plugin: "gh-copilot", id: 2, label: "GitHub Copilot (ID: 2)"}, + {plugin: "github", id: 3, label: "GitHub (ID: 3)"}, + } + + t.Run("filter to github", func(t *testing.T) { + got := filterChoicesByPlugin(choices, "github") + if len(got) != 2 { + t.Errorf("expected 2 github choices, got %d", len(got)) + } + for _, c := range got { + if c.plugin != "github" { + t.Errorf("unexpected plugin %q", c.plugin) + } + } + }) + + t.Run("filter to gh-copilot", func(t *testing.T) { + got := filterChoicesByPlugin(choices, "gh-copilot") + if len(got) != 1 { + t.Errorf("expected 1 copilot choice, got %d", len(got)) + } + }) + + t.Run("filter to unknown plugin returns empty", func(t *testing.T) { + got := filterChoicesByPlugin(choices, "gitlab") + if len(got) != 0 { + t.Errorf("expected 0 choices, got %d", len(got)) + } + }) + + t.Run("empty plugin slug returns empty", func(t *testing.T) { + got := filterChoicesByPlugin(choices, "") + if len(got) != 0 { + t.Errorf("expected 0 choices for empty plugin, got %d", len(got)) + } + }) +}