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
21 changes: 18 additions & 3 deletions cmd/configure_connection_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ var (
connOrg string
connEnterprise string
connToken string
connUsername string
connEnvFile string
connSkipClean bool
connName string
Expand All @@ -34,7 +35,8 @@ add connections for additional plugins.

Shared flags (all plugins):
--plugin Plugin to configure
--token Personal access token
--token Personal access token (or password for BasicAuth plugins)
--username Username for BasicAuth plugins (Jenkins, Bitbucket, Jira)
--name Connection display name
--endpoint API endpoint override
--proxy HTTP proxy URL
Expand All @@ -52,15 +54,19 @@ Example (GitHub):
gh devlake configure connection add --plugin github --token ghp_xxx --org my-org

Example (Copilot):
gh devlake configure connection add --plugin gh-copilot --token ghp_xxx --org my-org --enterprise my-ent`,
gh devlake configure connection add --plugin gh-copilot --token ghp_xxx --org my-org --enterprise my-ent

Example (Jenkins):
gh devlake configure connection add --plugin jenkins --username admin --token mypassword`,
RunE: runAddConnection,
}

func init() {
addConnectionCmd.Flags().StringVar(&connPlugin, "plugin", "", fmt.Sprintf("Plugin to configure (%s)", strings.Join(availablePluginSlugs(), ", ")))
addConnectionCmd.Flags().StringVar(&connOrg, "org", "", "Organization slug")
addConnectionCmd.Flags().StringVar(&connEnterprise, "enterprise", "", "Enterprise slug")
addConnectionCmd.Flags().StringVar(&connToken, "token", "", "Personal access token")
addConnectionCmd.Flags().StringVar(&connToken, "token", "", "Personal access token (used as password for BasicAuth plugins)")
addConnectionCmd.Flags().StringVar(&connUsername, "username", "", "Username for BasicAuth plugins (Jenkins, Bitbucket, Jira)")
addConnectionCmd.Flags().StringVar(&connEnvFile, "env-file", ".devlake.env", "Path to env file containing PAT")
addConnectionCmd.Flags().BoolVar(&connSkipClean, "skip-cleanup", false, "Do not delete .devlake.env after setup")
addConnectionCmd.Flags().StringVar(&connName, "name", "", "Connection display name (defaults to \"Plugin - org\")")
Expand Down Expand Up @@ -128,6 +134,15 @@ func runAddConnection(cmd *cobra.Command, args []string) error {
Proxy: connProxy,
Endpoint: connEndpoint,
}

// Resolve username for BasicAuth plugins
if def.NeedsUsername {
username := resolveUsername(def, connUsername, connEnvFile)
if username == "" {
return fmt.Errorf("username is required for %s (provide it via --username or at the prompt)", def.DisplayName)
}
params.Username = username
}
result, err := buildAndCreateConnection(client, def, params, org, true)
if err != nil {
return err
Expand Down
2 changes: 1 addition & 1 deletion cmd/configure_connection_add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ func TestNewAddConnectionCmd(t *testing.T) {
t.Error("expected Short to be set")
}

flags := []string{"plugin", "org", "enterprise", "token", "env-file", "skip-cleanup", "name", "proxy", "endpoint"}
flags := []string{"plugin", "org", "enterprise", "token", "username", "env-file", "skip-cleanup", "name", "proxy", "endpoint"}
for _, f := range flags {
if addConnectionCmd.Flags().Lookup(f) == nil {
t.Errorf("expected flag --%s to be registered on addConnectionCmd", f)
Expand Down
10 changes: 10 additions & 0 deletions cmd/configure_full.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ func runConnectionsInternal(defs []*ConnectionDef, org, enterprise, tokenVal, en
Org: pluginOrg,
Enterprise: pluginEnterprise,
}

// Resolve username per-plugin for BasicAuth
if def.NeedsUsername {
username := resolveUsername(def, "", envFile)
if username == "" {
fmt.Printf(" ⚠️ Username is required for %s, skipping\n", def.DisplayName)
continue
}
params.Username = username
}
r, err := buildAndCreateConnection(client, def, params, pluginOrg, true)
if err != nil {
fmt.Printf(" ⚠️ Could not create %s connection: %v\n", def.DisplayName, err)
Expand Down
77 changes: 72 additions & 5 deletions cmd/connection_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package cmd

import (
"fmt"
"os"
"strings"

"github.com/DevExpGBB/gh-devlake/internal/devlake"
"github.com/DevExpGBB/gh-devlake/internal/envfile"
"github.com/DevExpGBB/gh-devlake/internal/prompt"
)

Expand Down Expand Up @@ -37,6 +39,14 @@ type ConnectionDef struct {
ScopeFunc ScopeHandler // nil = scope configuration not yet supported
ScopeIDField string // JSON field name for the scope ID (e.g. "githubId", "id")
HasRepoScopes bool // true = scopes carry a FullName that should be tracked as repos

// Auth fields
AuthMethod string // "AccessToken" (default when empty), "BasicAuth", etc.
NeedsUsername bool // true for BasicAuth plugins (Jenkins, Bitbucket, Jira)
UsernamePrompt string // label for username prompt (e.g. "Jenkins username")
UsernameEnvVars []string // environment variable names for username resolution
UsernameEnvFileKeys []string // .devlake.env keys for username resolution
NeedsTokenExpiry bool // true = apply zero-date token expiry workaround on create
}

// MenuLabel returns the label for interactive menus.
Expand Down Expand Up @@ -67,6 +77,7 @@ func (d *ConnectionDef) defaultConnName(org string) string {
// ConnectionParams holds user-supplied values for a single connection.
type ConnectionParams struct {
Token string
Username string // for BasicAuth plugins (Jenkins, Bitbucket, Jira)
Org string
Enterprise string
Name string // override default connection name
Expand All @@ -82,6 +93,14 @@ func (d *ConnectionDef) rateLimitOrDefault() int {
return 4500
}

// authMethod returns the auth method for this plugin, defaulting to "AccessToken".
func (d *ConnectionDef) authMethod() string {
if d.AuthMethod != "" {
return d.AuthMethod
}
return "AccessToken"
}

// BuildCreateRequest constructs the API payload for creating this connection.
func (d *ConnectionDef) BuildCreateRequest(name string, params ConnectionParams) *devlake.ConnectionCreateRequest {
endpoint := d.Endpoint
Expand All @@ -92,11 +111,18 @@ func (d *ConnectionDef) BuildCreateRequest(name string, params ConnectionParams)
Name: name,
Endpoint: endpoint,
Proxy: params.Proxy,
AuthMethod: "AccessToken",
Token: params.Token,
AuthMethod: d.authMethod(),
RateLimitPerHour: d.rateLimitOrDefault(),
EnableGraphql: d.EnableGraphql,
}
if d.NeedsUsername && params.Username != "" {
// BasicAuth-style plugins (e.g., Jenkins, Bitbucket, Jira) expect credentials
// in username/password fields, not in the token field.
req.Username = params.Username
req.Password = params.Token
} else {
req.Token = params.Token
}
Comment on lines +118 to +125

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In BuildCreateRequest and BuildTestRequest, the branching condition d.NeedsUsername && params.Username != "" means that when NeedsUsername is true but params.Username is empty, the code falls to the else branch and sets req.Token = params.Token. This produces an internally inconsistent request: AuthMethod is "BasicAuth" but only the Token field is populated (no Username or Password). DevLake would likely reject such a payload.

While both current callers guard against this by checking for an empty username and erroring out before reaching this code, the request builder itself should be robust. The condition should check params.Username != "" independently of NeedsUsername (e.g., if params.Username != "" { req.Username = params.Username; req.Password = params.Token } else { req.Token = params.Token }), or the BasicAuth path should be guarded by d.AuthMethod == "BasicAuth" (or both). The same issue exists in BuildTestRequest.

Copilot uses AI. Check for mistakes.
if (d.NeedsOrg || d.NeedsOrgOrEnt) && params.Org != "" {
req.Organization = params.Org
}
Expand All @@ -115,12 +141,19 @@ func (d *ConnectionDef) BuildTestRequest(name string, params ConnectionParams) *
req := &devlake.ConnectionTestRequest{
Name: name,
Endpoint: endpoint,
AuthMethod: "AccessToken",
Token: params.Token,
AuthMethod: d.authMethod(),
RateLimitPerHour: d.rateLimitOrDefault(),
Proxy: params.Proxy,
EnableGraphql: d.EnableGraphql,
}
if d.NeedsUsername && params.Username != "" {
// BasicAuth-style plugins (e.g., Jenkins, Bitbucket, Jira) expect credentials
// in username/password fields, not in the token field.
req.Username = params.Username
req.Password = params.Token
} else {
req.Token = params.Token
}
if (d.NeedsOrg || d.NeedsOrgOrEnt) && params.Org != "" {
req.Organization = params.Org
}
Expand Down Expand Up @@ -148,6 +181,7 @@ var connectionRegistry = []*ConnectionDef{
ScopeFunc: scopeGitHubHandler,
ScopeIDField: "githubId",
HasRepoScopes: true,
NeedsTokenExpiry: true,
},
{
Plugin: "gh-copilot",
Expand All @@ -166,6 +200,7 @@ var connectionRegistry = []*ConnectionDef{
EnvFileKeys: []string{"GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"},
ScopeFunc: scopeCopilotHandler,
ScopeIDField: "id",
NeedsTokenExpiry: true,
},
{
Plugin: "gitlab",
Expand Down Expand Up @@ -280,7 +315,7 @@ func buildAndCreateConnection(client *devlake.Client, def *ConnectionDef, params
// zero-date (0000-00-00) under strict MySQL settings.
//
// PATs are effectively non-expiring, so use a far-future sentinel.
if (def.Plugin == "github" || def.Plugin == "gh-copilot") && looksLikeZeroDateTokenExpiresAt(err) {
if def.NeedsTokenExpiry && looksLikeZeroDateTokenExpiresAt(err) {
fmt.Println(" ⚠️ DevLake rejected empty token expiry; retrying with a non-expiring sentinel...")
createReq.TokenExpiresAt = "2099-01-01T00:00:00Z"
createReq.RefreshTokenExpiresAt = "2099-01-01T00:00:00Z"
Expand Down Expand Up @@ -309,6 +344,38 @@ func looksLikeZeroDateTokenExpiresAt(err error) bool {
return strings.Contains(msg, "token_expires_at") && strings.Contains(msg, "0000-00-00")
}

// resolveUsername resolves the username for a BasicAuth plugin.
// Priority: flag value → .devlake.env file (UsernameEnvFileKeys) →
// environment variables (UsernameEnvVars) → interactive prompt.
// Returns an empty string only if all resolution steps fail, including an empty
// interactive response or stdin EOF (for example, in non-terminal environments).
func resolveUsername(def *ConnectionDef, flagValue string, envFilePath string) string {
if flagValue != "" {
return flagValue
}
// Check env file
if envFilePath == "" {
envFilePath = ".devlake.env"
}
if vals, err := envfile.Load(envFilePath); err == nil {
for _, key := range def.UsernameEnvFileKeys {
if v, ok := vals[key]; ok && v != "" {
return v
}
}
}
for _, key := range def.UsernameEnvVars {
if v := os.Getenv(key); v != "" {
return v
}
}
label := def.UsernamePrompt
if label == "" {
label = fmt.Sprintf("%s username", def.DisplayName)
}
return prompt.ReadLine(label)
}
Comment on lines +347 to +377

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new resolveUsername() function has no tests, while the analogous token.Resolve() function in internal/token/resolve_test.go has comprehensive test coverage for all resolution paths (flag → env file → env var). The resolveUsername() function has its own resolution chain involving flagValue, def.UsernameEnvFileKeys, and def.UsernameEnvVars, and its behavior for the env file and env var paths is untested. Tests for these paths (flag priority, env file lookup, env var fallback) should be added analogously to the token resolution tests.

Copilot generated this review using guidance from repository custom instructions.

// aggregateScopeHints merges scope hints from multiple connection defs into one string.
func aggregateScopeHints(defs []*ConnectionDef) string {
seen := make(map[string]bool)
Expand Down
Loading