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
2 changes: 1 addition & 1 deletion AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ This project is the Buildkite CLI (`bk`)

## Commands
- Test: `go test ./...`
- Lint: `docker-compose -f .buildkite/docker-compose.yaml run lint`
- Lint: `docker-compose -f .buildkite/docker-compose.yaml run golangci-lint golangci-lint run`
- Generate: `go generate` (required after GraphQL changes)
- Run: `go run cmd/bk/main.go`

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ To get started with contributing, please follow these steps:
1. Fork the repository
2. Create a feature branch with a nice name (`git checkout -b cli-new-feature`) for your changes
3. Write your code
* We use `golangci-lint` and would be good to use the same in order to pass a PR merge. You can use `docker-compose -f .buildkite/docker-compose.yaml run lint` for that.
* We use `golangci-lint` and would be good to use the same in order to pass a PR merge. You can use `docker-compose -f .buildkite/docker-compose.yaml run golangci-lint golangci-lint run` for that.
* Make sure the tests are passing by running go test ./...
5. Commit your changes and push them to your forked repository.
7. Submit a pull request with a detailed description of your changes and linked to any relevant issues.
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ func (conf *Config) SetTokenForOrg(org, token string) error {
return conf.userConfig.WriteConfig()
}

// GetTokenForOrg gets the API token for a specific organization from the user configuration
func (conf *Config) GetTokenForOrg(org string) string {
key := fmt.Sprintf("organizations.%s.api_token", org)
return conf.userConfig.GetString(key)
}

func (conf *Config) ConfiguredOrganizations() []string {
m := conf.userConfig.GetStringMap("organizations")
orgs := slices.Collect(maps.Keys(m))
Expand Down
23 changes: 23 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,27 @@ func TestConfig(t *testing.T) {
t.Errorf("PreferredPipelines() does not match: %d", len(conf.PreferredPipelines()))
}
})

t.Run("GetTokenForOrg returns token for specific organization", func(t *testing.T) {
t.Parallel()

fs := afero.NewMemMapFs()
conf := New(fs, nil)

// Set tokens for different organizations
token1 := "token-org1"
token2 := "token-org2"
conf.SetTokenForOrg("org1", token1)
conf.SetTokenForOrg("org2", token2)

if conf.GetTokenForOrg("org1") != token1 {
t.Errorf("expected token for org1 to be %s, got %s", token1, conf.GetTokenForOrg("org1"))
}
if conf.GetTokenForOrg("org2") != token2 {
t.Errorf("expected token for org2 to be %s, got %s", token2, conf.GetTokenForOrg("org2"))
}
if conf.GetTokenForOrg("nonexistent") != "" {
t.Errorf("expected empty token for nonexistent org, got %s", conf.GetTokenForOrg("nonexistent"))
}
})
}
Binary file added main
Binary file not shown.
13 changes: 12 additions & 1 deletion pkg/cmd/configure/add/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ func ConfigureRun(f *factory.Factory) error {
return errors.New("organization slug cannot be empty")
}

// Check if token already exists for this organization
existingToken := getTokenForOrg(f, org)
if existingToken != "" {
fmt.Printf("Using existing API token for organization: %s\n", org)
return f.Config.SelectOrganization(org)
}

// Get API token with password input (no echo)
token, err := promptForInput("API Token: ", true)
if err != nil {
Expand All @@ -54,10 +61,14 @@ func ConfigureRun(f *factory.Factory) error {
}

fmt.Println("API token set for organization:", org)

return ConfigureWithCredentials(f, org, token)
}

// getTokenForOrg retrieves the token for a specific organization from the user config
func getTokenForOrg(f *factory.Factory, org string) string {
return f.Config.GetTokenForOrg(org)
}

// promptForInput handles terminal input with optional password masking
func promptForInput(prompt string, isPassword bool) (string, error) {
fmt.Print(prompt)
Expand Down
126 changes: 126 additions & 0 deletions pkg/cmd/configure/add/add_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package add

import (
"testing"

"github.com/buildkite/cli/v3/internal/config"
"github.com/buildkite/cli/v3/pkg/cmd/factory"
"github.com/spf13/afero"
)

func TestGetTokenForOrg(t *testing.T) {
t.Parallel()

t.Run("returns empty string when no token exists", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
conf := config.New(fs, nil)
f := &factory.Factory{Config: conf}

token := getTokenForOrg(f, "nonexistent")
if token != "" {
t.Errorf("expected empty string, got %s", token)
}
})

t.Run("returns token when it exists for organization", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
conf := config.New(fs, nil)
f := &factory.Factory{Config: conf}

// Set up a token for an organization
expectedToken := "bk_test_token_12345"
conf.SetTokenForOrg("test-org", expectedToken)

token := getTokenForOrg(f, "test-org")
if token != expectedToken {
t.Errorf("expected %s, got %s", expectedToken, token)
}
})

t.Run("returns different tokens for different organizations", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
conf := config.New(fs, nil)
f := &factory.Factory{Config: conf}

// Set up tokens for different organizations
token1 := "bk_test_token_org1"
token2 := "bk_test_token_org2"
conf.SetTokenForOrg("org1", token1)
conf.SetTokenForOrg("org2", token2)

if getTokenForOrg(f, "org1") != token1 {
t.Errorf("expected %s for org1", token1)
}
if getTokenForOrg(f, "org2") != token2 {
t.Errorf("expected %s for org2", token2)
}
})
}

func TestConfigureWithCredentials(t *testing.T) {
t.Parallel()

t.Run("configures organization and token", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
conf := config.New(fs, nil)
f := &factory.Factory{Config: conf}

org := "test-org"
token := "bk_test_token_12345"

err := ConfigureWithCredentials(f, org, token)
if err != nil {
t.Errorf("expected no error, got %s", err)
}

if conf.OrganizationSlug() != org {
t.Errorf("expected organization to be %s, got %s", org, conf.OrganizationSlug())
}

if conf.GetTokenForOrg(org) != token {
t.Errorf("expected token to be %s, got %s", token, conf.GetTokenForOrg(org))
}
})
}

func TestConfigureTokenReuse(t *testing.T) {
t.Parallel()

t.Run("reuses existing token when available", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
conf := config.New(fs, nil)
f := &factory.Factory{Config: conf}

org := "test-org"
existingToken := "bk_existing_token_12345"

// Pre-configure a token for the organization
conf.SetTokenForOrg(org, existingToken)

// Verify the token can be retrieved
retrievedToken := getTokenForOrg(f, org)
if retrievedToken != existingToken {
t.Errorf("expected to retrieve existing token %s, got %s", existingToken, retrievedToken)
}

// Configure with the existing token (simulating the logic in ConfigureRun)
err := ConfigureWithCredentials(f, org, retrievedToken)
if err != nil {
t.Errorf("expected no error, got %s", err)
}

// Verify the configuration still works
if conf.OrganizationSlug() != org {
t.Errorf("expected organization to be %s, got %s", org, conf.OrganizationSlug())
}

if conf.GetTokenForOrg(org) != existingToken {
t.Errorf("expected token to be %s, got %s", existingToken, conf.GetTokenForOrg(org))
}
})
}