Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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/<owner>/<repo>`
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
Expand All @@ -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.

---

Expand Down
34 changes: 26 additions & 8 deletions cmd/configure_full.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand All @@ -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 {
Expand All @@ -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")
Expand Down
26 changes: 25 additions & 1 deletion cmd/configure_projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand All @@ -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
}
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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 {
Expand Down
47 changes: 46 additions & 1 deletion cmd/configure_scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
var (
scopeOrg string
scopeEnterprise string
scopePlugin string
scopeRepos string
scopeReposFile string
scopeGHConnID int
Expand Down Expand Up @@ -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)")
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
124 changes: 123 additions & 1 deletion cmd/configure_scopes_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package cmd

import "testing"
import (
"testing"

"github.com/spf13/cobra"
)

func TestCopilotScopeID(t *testing.T) {
tests := []struct {
Expand Down Expand Up @@ -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))
}
})
}