Skip to content

Commit 8d41177

Browse files
committed
fix: skip optional prompts in flag mode for non-interactive scripting
connection add: - Add isInteractive detection (connPlugin == '') - Skip optional org prompt in flag mode (fixes #147) - Error instead of prompt for required org in flag mode - Pass isInteractive to buildAndCreateConnection - Error instead of prompt for required username in flag mode project add: - Add --connections flag for flag-driven blueprint wiring Format: 'plugin:connID,...' e.g. 'github:1,gh-copilot:1' - Auto-discovers all scopes on specified connections - Skips interactive connection selection loop in flag mode - Interactive mode (no --connections) unchanged Tests: - TestIsInteractive_FlagMode: verifies flag mode detection - TestIsInteractive_OrgPromptDecision: 4 cases for org prompt logic - TestParseConnectionSpecs: 7 cases (valid, empty, invalid format, invalid ID, unknown plugin, alias resolution, whitespace) Closes #147
1 parent 315bc47 commit 8d41177

5 files changed

Lines changed: 303 additions & 6 deletions

cmd/configure_connection_add.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ func init() {
7878
func runAddConnection(cmd *cobra.Command, args []string) error {
7979
printBanner("DevLake — Configure Connection")
8080

81+
// Flag mode = --plugin was provided → skip optional prompts.
82+
isInteractive := connPlugin == ""
83+
8184
// ── Select plugin ──
8285
def, err := selectPlugin(connPlugin)
8386
if err != nil {
@@ -88,14 +91,17 @@ func runAddConnection(cmd *cobra.Command, args []string) error {
8891
warnIrrelevantFlags(cmd, def, collectAllConnectionFlagDefs())
8992
// In interactive mode (no --plugin), also show contextual help for
9093
// the selected plugin's applicable flags.
91-
if connPlugin == "" {
94+
if isInteractive {
9295
printContextualFlagHelp(def, def.ConnectionFlags, "Connection")
9396
fmt.Println()
9497
}
9598

9699
// ── Prompt for org if needed ──
97100
org := connOrg
98101
if def.NeedsOrg && org == "" {
102+
if !isInteractive {
103+
return fmt.Errorf("--org is required for %s", def.DisplayName)
104+
}
99105
orgPrompt := def.OrgPrompt
100106
if orgPrompt == "" {
101107
orgPrompt = "Organization slug"
@@ -108,7 +114,8 @@ func runAddConnection(cmd *cobra.Command, args []string) error {
108114

109115
// Prompt for org optionally for plugins that don't require it,
110116
// so it gets saved to state for downstream commands (e.g. scopes).
111-
if !def.NeedsOrg && org == "" {
117+
// Only in interactive mode — flag mode skips optional prompts.
118+
if !def.NeedsOrg && org == "" && isInteractive {
112119
org = prompt.ReadLine("Organization slug (optional, press Enter to skip)")
113120
}
114121

@@ -148,11 +155,14 @@ func runAddConnection(cmd *cobra.Command, args []string) error {
148155
if def.NeedsUsername {
149156
username := resolveUsername(def, connUsername, connEnvFile)
150157
if username == "" {
158+
if !isInteractive {
159+
return fmt.Errorf("--username is required for %s", def.DisplayName)
160+
}
151161
return fmt.Errorf("username is required for %s (provide it via --username or at the prompt)", def.DisplayName)
152162
}
153163
params.Username = username
154164
}
155-
result, err := buildAndCreateConnection(client, def, params, org, true)
165+
result, err := buildAndCreateConnection(client, def, params, org, isInteractive)
156166
if err != nil {
157167
return err
158168
}

cmd/configure_connection_add_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,86 @@ func TestSelectPlugin_AzureDevOpsAlias(t *testing.T) {
6969
t.Errorf("expected plugin %q, got %q", "azuredevops_go", def.Plugin)
7070
}
7171
}
72+
73+
// TestIsInteractive_FlagMode verifies that setting --plugin triggers flag mode
74+
// (non-interactive), which is used by runAddConnection to skip optional prompts.
75+
func TestIsInteractive_FlagMode(t *testing.T) {
76+
// When --plugin is provided → not interactive
77+
pluginFlag := "github"
78+
isInteractive := pluginFlag == ""
79+
if isInteractive {
80+
t.Error("expected isInteractive=false when connPlugin is set")
81+
}
82+
83+
// When --plugin is empty → interactive
84+
pluginFlag = ""
85+
isInteractive = pluginFlag == ""
86+
if !isInteractive {
87+
t.Error("expected isInteractive=true when connPlugin is empty")
88+
}
89+
}
90+
91+
// TestIsInteractive_OrgPromptDecision verifies the org prompt logic:
92+
// - NeedsOrg=true + org="" + flag mode → should error (not prompt)
93+
// - NeedsOrg=false + org="" + flag mode → should skip (not prompt)
94+
// - NeedsOrg=false + org="" + interactive → would prompt (tested manually)
95+
func TestIsInteractive_OrgPromptDecision(t *testing.T) {
96+
tests := []struct {
97+
name string
98+
needsOrg bool
99+
org string
100+
isInteractive bool
101+
wantPrompt bool
102+
wantError bool
103+
}{
104+
{
105+
name: "NeedsOrg + no org + flag mode → error",
106+
needsOrg: true,
107+
org: "",
108+
isInteractive: false,
109+
wantPrompt: false,
110+
wantError: true,
111+
},
112+
{
113+
name: "NeedsOrg + org provided + flag mode → no prompt",
114+
needsOrg: true,
115+
org: "my-org",
116+
isInteractive: false,
117+
wantPrompt: false,
118+
wantError: false,
119+
},
120+
{
121+
name: "NoNeedsOrg + no org + flag mode → skip",
122+
needsOrg: false,
123+
org: "",
124+
isInteractive: false,
125+
wantPrompt: false,
126+
wantError: false,
127+
},
128+
{
129+
name: "NoNeedsOrg + no org + interactive → would prompt",
130+
needsOrg: false,
131+
org: "",
132+
isInteractive: true,
133+
wantPrompt: true,
134+
wantError: false,
135+
},
136+
}
137+
138+
for _, tc := range tests {
139+
t.Run(tc.name, func(t *testing.T) {
140+
// Simulate the org prompt decision logic from runAddConnection
141+
shouldPromptRequired := tc.needsOrg && tc.org == ""
142+
shouldError := shouldPromptRequired && !tc.isInteractive
143+
shouldPromptOptional := !tc.needsOrg && tc.org == "" && tc.isInteractive
144+
145+
if shouldError != tc.wantError {
146+
t.Errorf("error: got %v, want %v", shouldError, tc.wantError)
147+
}
148+
gotPrompt := shouldPromptOptional || (shouldPromptRequired && tc.isInteractive)
149+
if gotPrompt != tc.wantPrompt {
150+
t.Errorf("prompt: got %v, want %v", gotPrompt, tc.wantPrompt)
151+
}
152+
})
153+
}
154+
}

cmd/configure_project_add.go

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package cmd
22

33
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
47
"time"
58

69
"github.com/spf13/cobra"
10+
11+
"github.com/DevExpGBB/gh-devlake/internal/devlake"
712
)
813

914
func newProjectAddCmd() *cobra.Command {
@@ -26,15 +31,23 @@ This command will:
2631
4. Configure a sync blueprint
2732
5. Trigger the first data collection
2833
29-
Example:
34+
Flag mode (non-interactive):
35+
Provide --project-name and --connections to skip all prompts.
36+
--connections format: "plugin:connID,plugin:connID" e.g. "github:1,gh-copilot:1"
37+
All scopes on each specified connection are included automatically.
38+
39+
Example (interactive):
3040
gh devlake configure project add
31-
gh devlake configure project add --project-name my-team`,
41+
42+
Example (non-interactive):
43+
gh devlake configure project add --project-name my-team --connections "github:1,gh-copilot:1"`,
3244
RunE: func(cmd *cobra.Command, args []string) error {
3345
return runProjectAdd(cmd, args, &opts)
3446
},
3547
}
3648

3749
cmd.Flags().StringVar(&opts.ProjectName, "project-name", "", "DevLake project name")
50+
cmd.Flags().StringVar(&opts.Connections, "connections", "", `Connections to include: "plugin:connID,..." (flag mode)`)
3851
cmd.Flags().StringVar(&opts.TimeAfter, "time-after", "", "Only collect data after this date (default: 6 months ago)")
3952
cmd.Flags().StringVar(&opts.Cron, "cron", "0 0 * * *", "Blueprint cron schedule")
4053
cmd.Flags().BoolVar(&opts.SkipSync, "skip-sync", false, "Skip triggering the first data sync")
@@ -44,6 +57,112 @@ Example:
4457
return cmd
4558
}
4659

60+
// parseConnectionSpecs parses "github:1,gh-copilot:1" into connChoice entries.
61+
func parseConnectionSpecs(spec string) ([]connChoice, error) {
62+
if spec == "" {
63+
return nil, nil
64+
}
65+
parts := strings.Split(spec, ",")
66+
var choices []connChoice
67+
for _, p := range parts {
68+
p = strings.TrimSpace(p)
69+
if p == "" {
70+
continue
71+
}
72+
colonIdx := strings.LastIndex(p, ":")
73+
if colonIdx < 0 {
74+
return nil, fmt.Errorf("invalid connection spec %q — expected plugin:connID", p)
75+
}
76+
plugin := p[:colonIdx]
77+
idStr := p[colonIdx+1:]
78+
id, err := strconv.Atoi(idStr)
79+
if err != nil {
80+
return nil, fmt.Errorf("invalid connection ID %q in spec %q", idStr, p)
81+
}
82+
def := FindConnectionDef(plugin)
83+
if def == nil {
84+
return nil, fmt.Errorf("unknown plugin %q in connection spec", plugin)
85+
}
86+
choices = append(choices, connChoice{
87+
plugin: def.Plugin,
88+
id: id,
89+
label: fmt.Sprintf("%s (ID: %d)", def.DisplayName, id),
90+
})
91+
}
92+
return choices, nil
93+
}
94+
4795
func runProjectAdd(cmd *cobra.Command, args []string, opts *ProjectOpts) error {
96+
// Flag mode: --connections provided → non-interactive path
97+
if opts.Connections != "" {
98+
return runProjectAddFlagMode(cmd, args, opts)
99+
}
48100
return runConfigureProjects(cmd, args, opts)
49101
}
102+
103+
// runProjectAddFlagMode creates a project non-interactively using --connections.
104+
func runProjectAddFlagMode(cmd *cobra.Command, args []string, opts *ProjectOpts) error {
105+
printBanner("DevLake — Project Setup")
106+
107+
if opts.ProjectName == "" {
108+
return fmt.Errorf("--project-name is required when using --connections")
109+
}
110+
111+
specs, err := parseConnectionSpecs(opts.Connections)
112+
if err != nil {
113+
return fmt.Errorf("parsing --connections: %w", err)
114+
}
115+
if len(specs) == 0 {
116+
return fmt.Errorf("--connections must specify at least one connection")
117+
}
118+
119+
client := opts.Client
120+
statePath := opts.StatePath
121+
state := opts.State
122+
123+
// Discover DevLake if not pre-resolved by an orchestrator
124+
if client == nil {
125+
var disc *devlake.DiscoveryResult
126+
client, disc, err = discoverClient(cfgURL)
127+
if err != nil {
128+
return err
129+
}
130+
statePath, state = devlake.FindStateFile(disc.URL, disc.GrafanaURL)
131+
}
132+
133+
fmt.Printf("\n🔍 Discovering scopes for %d connection(s)...\n", len(specs))
134+
135+
var added []addedConnection
136+
for _, spec := range specs {
137+
ac, err := listConnectionScopes(client, spec)
138+
if err != nil {
139+
return fmt.Errorf("connection %s: %w", spec.label, err)
140+
}
141+
added = append(added, *ac)
142+
}
143+
144+
// Accumulate results
145+
var connections []devlake.BlueprintConnection
146+
var allRepos []string
147+
var pluginNames []string
148+
for _, a := range added {
149+
connections = append(connections, a.bpConn)
150+
allRepos = append(allRepos, a.repos...)
151+
pluginNames = append(pluginNames, pluginDisplayName(a.plugin))
152+
}
153+
154+
return finalizeProject(finalizeProjectOpts{
155+
Client: client,
156+
StatePath: statePath,
157+
State: state,
158+
ProjectName: opts.ProjectName,
159+
Connections: connections,
160+
Repos: allRepos,
161+
PluginNames: pluginNames,
162+
Cron: opts.Cron,
163+
TimeAfter: opts.TimeAfter,
164+
SkipSync: opts.SkipSync,
165+
Wait: opts.Wait,
166+
Timeout: opts.Timeout,
167+
})
168+
}

cmd/configure_project_test.go

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,98 @@ func TestNewProjectAddCmd_Flags(t *testing.T) {
6161
if cmd.Use != "add" {
6262
t.Errorf("expected Use %q, got %q", "add", cmd.Use)
6363
}
64-
flags := []string{"project-name", "time-after", "cron", "skip-sync", "wait", "timeout"}
64+
flags := []string{"project-name", "connections", "time-after", "cron", "skip-sync", "wait", "timeout"}
6565
for _, f := range flags {
6666
if cmd.Flags().Lookup(f) == nil {
6767
t.Errorf("expected flag --%s to be registered on project add cmd", f)
6868
}
6969
}
7070
}
7171

72+
func TestParseConnectionSpecs_Valid(t *testing.T) {
73+
specs, err := parseConnectionSpecs("github:1,gh-copilot:2")
74+
if err != nil {
75+
t.Fatalf("unexpected error: %v", err)
76+
}
77+
if len(specs) != 2 {
78+
t.Fatalf("expected 2 specs, got %d", len(specs))
79+
}
80+
if specs[0].plugin != "github" || specs[0].id != 1 {
81+
t.Errorf("spec[0]: got plugin=%q id=%d, want github:1", specs[0].plugin, specs[0].id)
82+
}
83+
if specs[1].plugin != "gh-copilot" || specs[1].id != 2 {
84+
t.Errorf("spec[1]: got plugin=%q id=%d, want gh-copilot:2", specs[1].plugin, specs[1].id)
85+
}
86+
}
87+
88+
func TestParseConnectionSpecs_Empty(t *testing.T) {
89+
specs, err := parseConnectionSpecs("")
90+
if err != nil {
91+
t.Fatalf("unexpected error: %v", err)
92+
}
93+
if specs != nil {
94+
t.Errorf("expected nil for empty string, got %v", specs)
95+
}
96+
}
97+
98+
func TestParseConnectionSpecs_InvalidFormat(t *testing.T) {
99+
_, err := parseConnectionSpecs("github-without-id")
100+
if err == nil {
101+
t.Fatal("expected error for missing colon separator")
102+
}
103+
}
104+
105+
func TestParseConnectionSpecs_InvalidID(t *testing.T) {
106+
_, err := parseConnectionSpecs("github:abc")
107+
if err == nil {
108+
t.Fatal("expected error for non-numeric ID")
109+
}
110+
}
111+
112+
func TestParseConnectionSpecs_UnknownPlugin(t *testing.T) {
113+
_, err := parseConnectionSpecs("nonexistent:1")
114+
if err == nil {
115+
t.Fatal("expected error for unknown plugin")
116+
}
117+
}
118+
119+
func TestParseConnectionSpecs_SingleConnection(t *testing.T) {
120+
specs, err := parseConnectionSpecs("jenkins:5")
121+
if err != nil {
122+
t.Fatalf("unexpected error: %v", err)
123+
}
124+
if len(specs) != 1 {
125+
t.Fatalf("expected 1 spec, got %d", len(specs))
126+
}
127+
if specs[0].plugin != "jenkins" || specs[0].id != 5 {
128+
t.Errorf("spec[0]: got plugin=%q id=%d, want jenkins:5", specs[0].plugin, specs[0].id)
129+
}
130+
}
131+
132+
func TestParseConnectionSpecs_PluginAlias(t *testing.T) {
133+
specs, err := parseConnectionSpecs("azure-devops:3")
134+
if err != nil {
135+
t.Fatalf("unexpected error: %v", err)
136+
}
137+
if len(specs) != 1 {
138+
t.Fatalf("expected 1 spec, got %d", len(specs))
139+
}
140+
// Should resolve the alias to the real plugin slug
141+
if specs[0].plugin != "azuredevops_go" {
142+
t.Errorf("expected plugin %q after alias resolution, got %q", "azuredevops_go", specs[0].plugin)
143+
}
144+
}
145+
146+
func TestParseConnectionSpecs_WhitespaceHandling(t *testing.T) {
147+
specs, err := parseConnectionSpecs(" github:1 , gh-copilot:2 ")
148+
if err != nil {
149+
t.Fatalf("unexpected error: %v", err)
150+
}
151+
if len(specs) != 2 {
152+
t.Fatalf("expected 2 specs, got %d", len(specs))
153+
}
154+
}
155+
72156
func TestNewProjectDeleteCmd_Flags(t *testing.T) {
73157
cmd := newProjectDeleteCmd()
74158
if cmd.Use != "delete" {

0 commit comments

Comments
 (0)