feat(cmd): add variable commands for pipeline variable management

This commit is contained in:
jmuraca
2026-01-27 12:59:17 -08:00
committed by avivsinai
parent c2de3eb822
commit ec421a082b
7 changed files with 2532 additions and 0 deletions
+6
View File
@@ -3,9 +3,12 @@
# Go source files
*.go text eol=lf
*.mod text eol=lf
*.sum text eol=lf
# Shell scripts
*.sh text eol=lf
*.bash text eol=lf
# Batch files (Windows)
*.bat text eol=crlf
@@ -20,6 +23,9 @@
# Makefiles
Makefile text eol=lf
Dockerfile text eol=lf
.gitignore text eol=lf
.gitattributes text eol=lf
# Binary files
*.png binary
+561
View File
@@ -0,0 +1,561 @@
package bbcloud
import (
"context"
"fmt"
"net/url"
)
// PipelineVariable represents a Bitbucket Cloud pipeline variable.
type PipelineVariable struct {
UUID string `json:"uuid"`
Key string `json:"key"`
Value string `json:"value,omitempty"`
Secured bool `json:"secured"`
}
// VariableListOptions configures variable list requests.
type VariableListOptions struct {
Limit int
}
type variableListPage struct {
Values []PipelineVariable `json:"values"`
Next string `json:"next"`
}
// ListRepositoryVariables lists pipeline variables for a repository.
func (c *Client) ListRepositoryVariables(ctx context.Context, workspace, repoSlug string, opts VariableListOptions) ([]PipelineVariable, error) {
if workspace == "" || repoSlug == "" {
return nil, fmt.Errorf("workspace and repository slug are required")
}
pageLen := opts.Limit
if pageLen <= 0 || pageLen > 100 {
pageLen = 100
}
path := fmt.Sprintf("/repositories/%s/%s/pipelines_config/variables?pagelen=%d",
url.PathEscape(workspace),
url.PathEscape(repoSlug),
pageLen,
)
var variables []PipelineVariable
for path != "" {
req, err := c.http.NewRequest(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var page variableListPage
if err := c.http.Do(req, &page); err != nil {
return nil, err
}
variables = append(variables, page.Values...)
if opts.Limit > 0 && len(variables) >= opts.Limit {
variables = variables[:opts.Limit]
break
}
if page.Next == "" {
break
}
nextURL, err := url.Parse(page.Next)
if err != nil {
return nil, err
}
path = nextURL.RequestURI()
}
return variables, nil
}
// CreateRepositoryVariableInput configures repository variable creation.
type CreateRepositoryVariableInput struct {
Key string
Value string
Secured bool
}
// CreateRepositoryVariable creates a pipeline variable for a repository.
func (c *Client) CreateRepositoryVariable(ctx context.Context, workspace, repoSlug string, input CreateRepositoryVariableInput) (*PipelineVariable, error) {
if workspace == "" || repoSlug == "" {
return nil, fmt.Errorf("workspace and repository slug are required")
}
if input.Key == "" {
return nil, fmt.Errorf("variable key is required")
}
body := map[string]any{
"key": input.Key,
"value": input.Value,
"secured": input.Secured,
}
path := fmt.Sprintf("/repositories/%s/%s/pipelines_config/variables",
url.PathEscape(workspace),
url.PathEscape(repoSlug),
)
req, err := c.http.NewRequest(ctx, "POST", path, body)
if err != nil {
return nil, err
}
var variable PipelineVariable
if err := c.http.Do(req, &variable); err != nil {
return nil, err
}
return &variable, nil
}
// UpdateRepositoryVariableInput configures repository variable updates.
type UpdateRepositoryVariableInput struct {
Key string
Value string
Secured bool
}
// UpdateRepositoryVariable updates a pipeline variable for a repository.
// The variableUUID identifies the variable to update.
func (c *Client) UpdateRepositoryVariable(ctx context.Context, workspace, repoSlug, variableUUID string, input UpdateRepositoryVariableInput) (*PipelineVariable, error) {
if workspace == "" || repoSlug == "" {
return nil, fmt.Errorf("workspace and repository slug are required")
}
if variableUUID == "" {
return nil, fmt.Errorf("variable UUID is required")
}
if input.Key == "" {
return nil, fmt.Errorf("variable key is required")
}
body := map[string]any{
"key": input.Key,
"value": input.Value,
"secured": input.Secured,
}
path := fmt.Sprintf("/repositories/%s/%s/pipelines_config/variables/%s",
url.PathEscape(workspace),
url.PathEscape(repoSlug),
url.PathEscape(variableUUID),
)
req, err := c.http.NewRequest(ctx, "PUT", path, body)
if err != nil {
return nil, err
}
var variable PipelineVariable
if err := c.http.Do(req, &variable); err != nil {
return nil, err
}
return &variable, nil
}
// DeleteRepositoryVariable deletes a pipeline variable from a repository.
func (c *Client) DeleteRepositoryVariable(ctx context.Context, workspace, repoSlug, variableUUID string) error {
if workspace == "" || repoSlug == "" {
return fmt.Errorf("workspace and repository slug are required")
}
if variableUUID == "" {
return fmt.Errorf("variable UUID is required")
}
path := fmt.Sprintf("/repositories/%s/%s/pipelines_config/variables/%s",
url.PathEscape(workspace),
url.PathEscape(repoSlug),
url.PathEscape(variableUUID),
)
req, err := c.http.NewRequest(ctx, "DELETE", path, nil)
if err != nil {
return err
}
return c.http.Do(req, nil)
}
// --- Workspace-level variable methods ---
// ListWorkspaceVariables lists pipeline variables for a workspace.
func (c *Client) ListWorkspaceVariables(ctx context.Context, workspace string, opts VariableListOptions) ([]PipelineVariable, error) {
if workspace == "" {
return nil, fmt.Errorf("workspace is required")
}
pageLen := opts.Limit
if pageLen <= 0 || pageLen > 100 {
pageLen = 100
}
path := fmt.Sprintf("/workspaces/%s/pipelines-config/variables?pagelen=%d",
url.PathEscape(workspace),
pageLen,
)
var variables []PipelineVariable
for path != "" {
req, err := c.http.NewRequest(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var page variableListPage
if err := c.http.Do(req, &page); err != nil {
return nil, err
}
variables = append(variables, page.Values...)
if opts.Limit > 0 && len(variables) >= opts.Limit {
variables = variables[:opts.Limit]
break
}
if page.Next == "" {
break
}
nextURL, err := url.Parse(page.Next)
if err != nil {
return nil, err
}
path = nextURL.RequestURI()
}
return variables, nil
}
// CreateWorkspaceVariableInput configures workspace variable creation.
type CreateWorkspaceVariableInput struct {
Key string
Value string
Secured bool
}
// CreateWorkspaceVariable creates a pipeline variable for a workspace.
func (c *Client) CreateWorkspaceVariable(ctx context.Context, workspace string, input CreateWorkspaceVariableInput) (*PipelineVariable, error) {
if workspace == "" {
return nil, fmt.Errorf("workspace is required")
}
if input.Key == "" {
return nil, fmt.Errorf("variable key is required")
}
body := map[string]any{
"key": input.Key,
"value": input.Value,
"secured": input.Secured,
}
path := fmt.Sprintf("/workspaces/%s/pipelines-config/variables",
url.PathEscape(workspace),
)
req, err := c.http.NewRequest(ctx, "POST", path, body)
if err != nil {
return nil, err
}
var variable PipelineVariable
if err := c.http.Do(req, &variable); err != nil {
return nil, err
}
return &variable, nil
}
// UpdateWorkspaceVariableInput configures workspace variable updates.
type UpdateWorkspaceVariableInput struct {
Key string
Value string
Secured bool
}
// UpdateWorkspaceVariable updates a pipeline variable for a workspace.
func (c *Client) UpdateWorkspaceVariable(ctx context.Context, workspace, variableUUID string, input UpdateWorkspaceVariableInput) (*PipelineVariable, error) {
if workspace == "" {
return nil, fmt.Errorf("workspace is required")
}
if variableUUID == "" {
return nil, fmt.Errorf("variable UUID is required")
}
if input.Key == "" {
return nil, fmt.Errorf("variable key is required")
}
body := map[string]any{
"key": input.Key,
"value": input.Value,
"secured": input.Secured,
}
path := fmt.Sprintf("/workspaces/%s/pipelines-config/variables/%s",
url.PathEscape(workspace),
url.PathEscape(variableUUID),
)
req, err := c.http.NewRequest(ctx, "PUT", path, body)
if err != nil {
return nil, err
}
var variable PipelineVariable
if err := c.http.Do(req, &variable); err != nil {
return nil, err
}
return &variable, nil
}
// DeleteWorkspaceVariable deletes a pipeline variable from a workspace.
func (c *Client) DeleteWorkspaceVariable(ctx context.Context, workspace, variableUUID string) error {
if workspace == "" {
return fmt.Errorf("workspace is required")
}
if variableUUID == "" {
return fmt.Errorf("variable UUID is required")
}
path := fmt.Sprintf("/workspaces/%s/pipelines-config/variables/%s",
url.PathEscape(workspace),
url.PathEscape(variableUUID),
)
req, err := c.http.NewRequest(ctx, "DELETE", path, nil)
if err != nil {
return err
}
return c.http.Do(req, nil)
}
// --- Deployment environment variable methods ---
// DeploymentEnvironment represents a deployment environment in Bitbucket Cloud.
type DeploymentEnvironment struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Slug string `json:"slug"`
EnvironmentType struct {
Name string `json:"name"`
} `json:"environment_type"`
}
type deploymentEnvironmentListPage struct {
Values []DeploymentEnvironment `json:"values"`
Next string `json:"next"`
}
// ListDeploymentEnvironments lists deployment environments for a repository.
func (c *Client) ListDeploymentEnvironments(ctx context.Context, workspace, repoSlug string) ([]DeploymentEnvironment, error) {
if workspace == "" || repoSlug == "" {
return nil, fmt.Errorf("workspace and repository slug are required")
}
path := fmt.Sprintf("/repositories/%s/%s/environments?pagelen=100",
url.PathEscape(workspace),
url.PathEscape(repoSlug),
)
var environments []DeploymentEnvironment
for path != "" {
req, err := c.http.NewRequest(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var page deploymentEnvironmentListPage
if err := c.http.Do(req, &page); err != nil {
return nil, err
}
environments = append(environments, page.Values...)
if page.Next == "" {
break
}
nextURL, err := url.Parse(page.Next)
if err != nil {
return nil, err
}
path = nextURL.RequestURI()
}
return environments, nil
}
// ListDeploymentVariables lists pipeline variables for a deployment environment.
func (c *Client) ListDeploymentVariables(ctx context.Context, workspace, repoSlug, environmentUUID string, opts VariableListOptions) ([]PipelineVariable, error) {
if workspace == "" || repoSlug == "" {
return nil, fmt.Errorf("workspace and repository slug are required")
}
if environmentUUID == "" {
return nil, fmt.Errorf("environment UUID is required")
}
pageLen := opts.Limit
if pageLen <= 0 || pageLen > 100 {
pageLen = 100
}
path := fmt.Sprintf("/repositories/%s/%s/deployments_config/environments/%s/variables?pagelen=%d",
url.PathEscape(workspace),
url.PathEscape(repoSlug),
url.PathEscape(environmentUUID),
pageLen,
)
var variables []PipelineVariable
for path != "" {
req, err := c.http.NewRequest(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var page variableListPage
if err := c.http.Do(req, &page); err != nil {
return nil, err
}
variables = append(variables, page.Values...)
if opts.Limit > 0 && len(variables) >= opts.Limit {
variables = variables[:opts.Limit]
break
}
if page.Next == "" {
break
}
nextURL, err := url.Parse(page.Next)
if err != nil {
return nil, err
}
path = nextURL.RequestURI()
}
return variables, nil
}
// CreateDeploymentVariableInput configures deployment variable creation.
type CreateDeploymentVariableInput struct {
Key string
Value string
Secured bool
}
// CreateDeploymentVariable creates a pipeline variable for a deployment environment.
func (c *Client) CreateDeploymentVariable(ctx context.Context, workspace, repoSlug, environmentUUID string, input CreateDeploymentVariableInput) (*PipelineVariable, error) {
if workspace == "" || repoSlug == "" {
return nil, fmt.Errorf("workspace and repository slug are required")
}
if environmentUUID == "" {
return nil, fmt.Errorf("environment UUID is required")
}
if input.Key == "" {
return nil, fmt.Errorf("variable key is required")
}
body := map[string]any{
"key": input.Key,
"value": input.Value,
"secured": input.Secured,
}
path := fmt.Sprintf("/repositories/%s/%s/deployments_config/environments/%s/variables",
url.PathEscape(workspace),
url.PathEscape(repoSlug),
url.PathEscape(environmentUUID),
)
req, err := c.http.NewRequest(ctx, "POST", path, body)
if err != nil {
return nil, err
}
var variable PipelineVariable
if err := c.http.Do(req, &variable); err != nil {
return nil, err
}
return &variable, nil
}
// UpdateDeploymentVariableInput configures deployment variable updates.
type UpdateDeploymentVariableInput struct {
Key string
Value string
Secured bool
}
// UpdateDeploymentVariable updates a pipeline variable for a deployment environment.
func (c *Client) UpdateDeploymentVariable(ctx context.Context, workspace, repoSlug, environmentUUID, variableUUID string, input UpdateDeploymentVariableInput) (*PipelineVariable, error) {
if workspace == "" || repoSlug == "" {
return nil, fmt.Errorf("workspace and repository slug are required")
}
if environmentUUID == "" {
return nil, fmt.Errorf("environment UUID is required")
}
if variableUUID == "" {
return nil, fmt.Errorf("variable UUID is required")
}
if input.Key == "" {
return nil, fmt.Errorf("variable key is required")
}
body := map[string]any{
"key": input.Key,
"value": input.Value,
"secured": input.Secured,
}
path := fmt.Sprintf("/repositories/%s/%s/deployments_config/environments/%s/variables/%s",
url.PathEscape(workspace),
url.PathEscape(repoSlug),
url.PathEscape(environmentUUID),
url.PathEscape(variableUUID),
)
req, err := c.http.NewRequest(ctx, "PUT", path, body)
if err != nil {
return nil, err
}
var variable PipelineVariable
if err := c.http.Do(req, &variable); err != nil {
return nil, err
}
return &variable, nil
}
// DeleteDeploymentVariable deletes a pipeline variable from a deployment environment.
func (c *Client) DeleteDeploymentVariable(ctx context.Context, workspace, repoSlug, environmentUUID, variableUUID string) error {
if workspace == "" || repoSlug == "" {
return fmt.Errorf("workspace and repository slug are required")
}
if environmentUUID == "" {
return fmt.Errorf("environment UUID is required")
}
if variableUUID == "" {
return fmt.Errorf("variable UUID is required")
}
path := fmt.Sprintf("/repositories/%s/%s/deployments_config/environments/%s/variables/%s",
url.PathEscape(workspace),
url.PathEscape(repoSlug),
url.PathEscape(environmentUUID),
url.PathEscape(variableUUID),
)
req, err := c.http.NewRequest(ctx, "DELETE", path, nil)
if err != nil {
return err
}
return c.http.Do(req, nil)
}
+433
View File
@@ -0,0 +1,433 @@
package bbcloud
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestListRepositoryVariablesValidation(t *testing.T) {
client, err := New(Options{BaseURL: "https://api.bitbucket.org/2.0"})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
tests := []struct {
name string
workspace string
repoSlug string
errorContains string
}{
{
name: "missing workspace",
workspace: "",
repoSlug: "repo",
errorContains: "workspace and repository slug are required",
},
{
name: "missing repo slug",
workspace: "workspace",
repoSlug: "",
errorContains: "workspace and repository slug are required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := client.ListRepositoryVariables(ctx, tt.workspace, tt.repoSlug, VariableListOptions{})
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.errorContains)
}
if !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("expected error containing %q, got %q", tt.errorContains, err.Error())
}
})
}
}
func TestListRepositoryVariablesPagination(t *testing.T) {
var requestCount int
var serverURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.Header().Set("Content-Type", "application/json")
switch requestCount {
case 1:
resp := variableListPage{
Values: []PipelineVariable{
{UUID: "{uuid-1}", Key: "VAR1", Value: "value1"},
{UUID: "{uuid-2}", Key: "VAR2", Value: "value2"},
},
Next: serverURL + "/repositories/ws/repo/pipelines_config/variables?page=2",
}
_ = json.NewEncoder(w).Encode(resp)
case 2:
resp := variableListPage{
Values: []PipelineVariable{
{UUID: "{uuid-3}", Key: "VAR3", Value: "value3"},
},
}
_ = json.NewEncoder(w).Encode(resp)
default:
t.Fatalf("unexpected request %d", requestCount)
}
}))
serverURL = server.URL
t.Cleanup(server.Close)
client, err := New(Options{BaseURL: server.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
variables, err := client.ListRepositoryVariables(ctx, "ws", "repo", VariableListOptions{})
if err != nil {
t.Fatalf("ListRepositoryVariables: %v", err)
}
if len(variables) != 3 {
t.Errorf("expected 3 variables, got %d", len(variables))
}
if requestCount != 2 {
t.Errorf("expected 2 requests for pagination, got %d", requestCount)
}
}
func TestListRepositoryVariablesRespectsLimit(t *testing.T) {
var requestCount int
var serverURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.Header().Set("Content-Type", "application/json")
resp := variableListPage{
Values: []PipelineVariable{
{UUID: "{uuid-1}", Key: "VAR1"},
{UUID: "{uuid-2}", Key: "VAR2"},
{UUID: "{uuid-3}", Key: "VAR3"},
},
Next: serverURL + "/repositories/ws/repo/pipelines_config/variables?page=2",
}
_ = json.NewEncoder(w).Encode(resp)
}))
serverURL = server.URL
t.Cleanup(server.Close)
client, err := New(Options{BaseURL: server.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
variables, err := client.ListRepositoryVariables(ctx, "ws", "repo", VariableListOptions{Limit: 2})
if err != nil {
t.Fatalf("ListRepositoryVariables: %v", err)
}
if len(variables) != 2 {
t.Errorf("expected 2 variables (limit), got %d", len(variables))
}
if requestCount != 1 {
t.Errorf("expected 1 request (limit satisfied), got %d", requestCount)
}
}
func TestCreateRepositoryVariableValidation(t *testing.T) {
client, err := New(Options{BaseURL: "https://api.bitbucket.org/2.0"})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
tests := []struct {
name string
workspace string
repoSlug string
input CreateRepositoryVariableInput
errorContains string
}{
{
name: "missing workspace",
workspace: "",
repoSlug: "repo",
input: CreateRepositoryVariableInput{Key: "VAR1", Value: "value"},
errorContains: "workspace and repository slug are required",
},
{
name: "missing repo slug",
workspace: "workspace",
repoSlug: "",
input: CreateRepositoryVariableInput{Key: "VAR1", Value: "value"},
errorContains: "workspace and repository slug are required",
},
{
name: "missing key",
workspace: "workspace",
repoSlug: "repo",
input: CreateRepositoryVariableInput{Key: "", Value: "value"},
errorContains: "variable key is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := client.CreateRepositoryVariable(ctx, tt.workspace, tt.repoSlug, tt.input)
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.errorContains)
}
if !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("expected error containing %q, got %q", tt.errorContains, err.Error())
}
})
}
}
func TestCreateRepositoryVariable(t *testing.T) {
var capturedBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("expected POST, got %s", r.Method)
}
_ = json.NewDecoder(r.Body).Decode(&capturedBody)
w.Header().Set("Content-Type", "application/json")
resp := PipelineVariable{
UUID: "{new-uuid}",
Key: capturedBody["key"].(string),
Secured: capturedBody["secured"].(bool),
}
_ = json.NewEncoder(w).Encode(resp)
}))
t.Cleanup(server.Close)
client, err := New(Options{BaseURL: server.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
variable, err := client.CreateRepositoryVariable(ctx, "ws", "repo", CreateRepositoryVariableInput{
Key: "MY_VAR",
Value: "secret",
Secured: true,
})
if err != nil {
t.Fatalf("CreateRepositoryVariable: %v", err)
}
if variable.Key != "MY_VAR" {
t.Errorf("expected key MY_VAR, got %s", variable.Key)
}
if !variable.Secured {
t.Error("expected variable to be secured")
}
if capturedBody["key"] != "MY_VAR" {
t.Errorf("expected request body key=MY_VAR, got %v", capturedBody["key"])
}
if capturedBody["value"] != "secret" {
t.Errorf("expected request body value=secret, got %v", capturedBody["value"])
}
if capturedBody["secured"] != true {
t.Errorf("expected request body secured=true, got %v", capturedBody["secured"])
}
}
func TestUpdateRepositoryVariableValidation(t *testing.T) {
client, err := New(Options{BaseURL: "https://api.bitbucket.org/2.0"})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
tests := []struct {
name string
workspace string
repoSlug string
uuid string
input UpdateRepositoryVariableInput
errorContains string
}{
{
name: "missing workspace",
workspace: "",
repoSlug: "repo",
uuid: "{uuid}",
input: UpdateRepositoryVariableInput{Key: "VAR1", Value: "value"},
errorContains: "workspace and repository slug are required",
},
{
name: "missing uuid",
workspace: "workspace",
repoSlug: "repo",
uuid: "",
input: UpdateRepositoryVariableInput{Key: "VAR1", Value: "value"},
errorContains: "variable UUID is required",
},
{
name: "missing key",
workspace: "workspace",
repoSlug: "repo",
uuid: "{uuid}",
input: UpdateRepositoryVariableInput{Key: "", Value: "value"},
errorContains: "variable key is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := client.UpdateRepositoryVariable(ctx, tt.workspace, tt.repoSlug, tt.uuid, tt.input)
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.errorContains)
}
if !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("expected error containing %q, got %q", tt.errorContains, err.Error())
}
})
}
}
func TestDeleteRepositoryVariableValidation(t *testing.T) {
client, err := New(Options{BaseURL: "https://api.bitbucket.org/2.0"})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
tests := []struct {
name string
workspace string
repoSlug string
uuid string
errorContains string
}{
{
name: "missing workspace",
workspace: "",
repoSlug: "repo",
uuid: "{uuid}",
errorContains: "workspace and repository slug are required",
},
{
name: "missing uuid",
workspace: "workspace",
repoSlug: "repo",
uuid: "",
errorContains: "variable UUID is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := client.DeleteRepositoryVariable(ctx, tt.workspace, tt.repoSlug, tt.uuid)
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.errorContains)
}
if !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("expected error containing %q, got %q", tt.errorContains, err.Error())
}
})
}
}
func TestListWorkspaceVariablesValidation(t *testing.T) {
client, err := New(Options{BaseURL: "https://api.bitbucket.org/2.0"})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
_, err = client.ListWorkspaceVariables(ctx, "", VariableListOptions{})
if err == nil {
t.Fatal("expected error for missing workspace, got nil")
}
if !strings.Contains(err.Error(), "workspace is required") {
t.Errorf("expected error about workspace, got %q", err.Error())
}
}
func TestListDeploymentEnvironments(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/environments") {
t.Errorf("expected path to contain /environments, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
resp := deploymentEnvironmentListPage{
Values: []DeploymentEnvironment{
{UUID: "{env-1}", Name: "production", Slug: "production"},
{UUID: "{env-2}", Name: "staging", Slug: "staging"},
},
}
_ = json.NewEncoder(w).Encode(resp)
}))
t.Cleanup(server.Close)
client, err := New(Options{BaseURL: server.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
environments, err := client.ListDeploymentEnvironments(ctx, "ws", "repo")
if err != nil {
t.Fatalf("ListDeploymentEnvironments: %v", err)
}
if len(environments) != 2 {
t.Errorf("expected 2 environments, got %d", len(environments))
}
if environments[0].Name != "production" {
t.Errorf("expected first environment to be production, got %s", environments[0].Name)
}
}
func TestListDeploymentVariables(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/deployments_config/environments/") {
t.Errorf("expected path to contain /deployments_config/environments/, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
resp := variableListPage{
Values: []PipelineVariable{
{UUID: "{var-1}", Key: "DEPLOY_VAR", Value: "value1"},
},
}
_ = json.NewEncoder(w).Encode(resp)
}))
t.Cleanup(server.Close)
client, err := New(Options{BaseURL: server.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
variables, err := client.ListDeploymentVariables(ctx, "ws", "repo", "{env-uuid}", VariableListOptions{})
if err != nil {
t.Fatalf("ListDeploymentVariables: %v", err)
}
if len(variables) != 1 {
t.Errorf("expected 1 variable, got %d", len(variables))
}
if variables[0].Key != "DEPLOY_VAR" {
t.Errorf("expected key DEPLOY_VAR, got %s", variables[0].Key)
}
}
+2
View File
@@ -16,6 +16,7 @@ import (
"github.com/avivsinai/bitbucket-cli/pkg/cmd/project"
"github.com/avivsinai/bitbucket-cli/pkg/cmd/repo"
"github.com/avivsinai/bitbucket-cli/pkg/cmd/status"
"github.com/avivsinai/bitbucket-cli/pkg/cmd/variable"
"github.com/avivsinai/bitbucket-cli/pkg/cmd/webhook"
"github.com/avivsinai/bitbucket-cli/pkg/cmdutil"
)
@@ -62,6 +63,7 @@ Common flows:
webhook.NewCommand(f),
status.NewCmdStatus(f),
pipeline.NewCmdPipeline(f),
variable.NewCommand(f),
api.NewCmdAPI(f),
extension.NewCmdExtension(f),
)
File diff suppressed because it is too large Load Diff
+312
View File
@@ -0,0 +1,312 @@
package variable
import (
"os"
"path/filepath"
"testing"
)
func TestValidateVariableKey(t *testing.T) {
tests := []struct {
name string
key string
wantErr bool
errContains string
}{
{
name: "valid simple key",
key: "MY_VAR",
wantErr: false,
},
{
name: "valid lowercase key",
key: "my_var",
wantErr: false,
},
{
name: "valid mixed case key",
key: "MyVar",
wantErr: false,
},
{
name: "valid key with numbers",
key: "VAR123",
wantErr: false,
},
{
name: "valid single letter",
key: "A",
wantErr: false,
},
{
name: "valid key starting with underscore after letter",
key: "A_B_C",
wantErr: false,
},
{
name: "empty key",
key: "",
wantErr: true,
errContains: "cannot be empty",
},
{
name: "key starting with number",
key: "123VAR",
wantErr: true,
errContains: "must start with a letter",
},
{
name: "key starting with underscore",
key: "_MY_VAR",
wantErr: true,
errContains: "must start with a letter",
},
{
name: "key with hyphen",
key: "MY-VAR",
wantErr: true,
errContains: "invalid character",
},
{
name: "key with space",
key: "MY VAR",
wantErr: true,
errContains: "invalid character",
},
{
name: "key with special character",
key: "MY$VAR",
wantErr: true,
errContains: "invalid character",
},
{
name: "key with dot",
key: "MY.VAR",
wantErr: true,
errContains: "invalid character",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateVariableKey(tt.key)
if tt.wantErr {
if err == nil {
t.Errorf("expected error containing %q, got nil", tt.errContains)
return
}
if tt.errContains != "" {
if !containsString(err.Error(), tt.errContains) {
t.Errorf("expected error containing %q, got %q", tt.errContains, err.Error())
}
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}
func TestParseEnvFile(t *testing.T) {
// Note: Test data uses obviously fake variable names and values.
// These are NOT real credentials - they are test fixtures for env file parsing.
tests := []struct {
name string
content string
want map[string]string
wantErr bool
errContains string
}{
{
name: "simple key value pairs",
content: "FOO=bar\nBAZ=qux",
want: map[string]string{
"FOO": "bar",
"BAZ": "qux",
},
},
{
name: "with comments",
content: "# This is a comment\nFOO=bar\n# Another comment\nBAZ=qux",
want: map[string]string{
"FOO": "bar",
"BAZ": "qux",
},
},
{
name: "with empty lines",
content: "FOO=bar\n\n\nBAZ=qux\n",
want: map[string]string{
"FOO": "bar",
"BAZ": "qux",
},
},
{
name: "with double quotes",
content: "FOO=\"hello world\"",
want: map[string]string{
"FOO": "hello world",
},
},
{
name: "with single quotes",
content: "FOO='hello world'",
want: map[string]string{
"FOO": "hello world",
},
},
{
name: "empty value",
content: "FOO=",
want: map[string]string{
"FOO": "",
},
},
{
name: "value with equals sign",
content: "FOO=a=b=c",
want: map[string]string{
"FOO": "a=b=c",
},
},
{
name: "whitespace around key",
content: " FOO =bar",
want: map[string]string{
"FOO": "bar",
},
},
{
name: "leading/trailing whitespace in line",
content: " FOO=bar \n BAZ=qux ",
want: map[string]string{
"FOO": "bar",
"BAZ": "qux",
},
},
{
name: "missing equals sign",
content: "FOO bar",
wantErr: true,
errContains: "invalid format",
},
{
name: "empty key",
content: "=bar",
wantErr: true,
errContains: "empty key",
},
{
name: "empty file",
content: "",
want: map[string]string{},
},
{
name: "only comments and empty lines",
content: "# comment 1\n\n# comment 2\n",
want: map[string]string{},
},
{
name: "value with hash not a comment",
content: "FOO=bar#baz#qux",
want: map[string]string{
"FOO": "bar#baz#qux",
},
},
{
name: "quoted value with mixed chars",
content: "FOO=\"hello 'world' and more\"",
want: map[string]string{
"FOO": "hello 'world' and more",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a temp file with the content
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "test.env")
err := os.WriteFile(tmpFile, []byte(tt.content), 0644)
if err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
got, err := parseEnvFile(tmpFile)
if tt.wantErr {
if err == nil {
t.Errorf("expected error containing %q, got nil", tt.errContains)
return
}
if tt.errContains != "" {
if !containsString(err.Error(), tt.errContains) {
t.Errorf("expected error containing %q, got %q", tt.errContains, err.Error())
}
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tt.want) {
t.Errorf("got %d entries, want %d", len(got), len(tt.want))
}
for k, v := range tt.want {
if got[k] != v {
t.Errorf("key %q: got %q, want %q", k, got[k], v)
}
}
})
}
}
func TestParseEnvFileNotFound(t *testing.T) {
_, err := parseEnvFile("/nonexistent/path/to/file.env")
if err == nil {
t.Error("expected error for nonexistent file, got nil")
}
if !containsString(err.Error(), "failed to open") {
t.Errorf("expected error about opening file, got %q", err.Error())
}
}
func TestScopeConstants(t *testing.T) {
// Verify scope constants have expected values
tests := []struct {
name string
constant string
expected string
}{
{"repository", scopeRepository, "repository"},
{"workspace", scopeWorkspace, "workspace"},
{"deployment", scopeDeployment, "deployment"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.constant != tt.expected {
t.Errorf("scope constant %s = %q, want %q", tt.name, tt.constant, tt.expected)
}
})
}
}
// containsString is a helper to check if a string contains a substring
func containsString(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+28
View File
@@ -4,14 +4,17 @@ import (
"bufio"
"errors"
"fmt"
"os"
"strings"
"github.com/avivsinai/bitbucket-cli/pkg/iostreams"
"golang.org/x/term"
)
// Interface exposes interactive prompt helpers used by commands.
type Interface interface {
Input(prompt, defaultValue string) (string, error)
Password(prompt string) (string, error)
Confirm(prompt string, defaultYes bool) (bool, error)
}
@@ -60,6 +63,31 @@ func (p *system) Input(prompt, defaultValue string) (string, error) {
return line, nil
}
func (p *system) Password(prompt string) (string, error) {
if p.ios == nil || !p.ios.CanPrompt() {
return "", errors.New("interactive prompts require a TTY")
}
if _, err := fmt.Fprint(p.ios.Out, prompt+": "); err != nil {
return "", err
}
// Get the file descriptor for stdin to disable echo
stdin, ok := p.ios.In.(*os.File)
if !ok {
return "", errors.New("password input requires a terminal")
}
password, err := term.ReadPassword(int(stdin.Fd()))
// Print newline since ReadPassword doesn't echo the Enter key
_, _ = fmt.Fprintln(p.ios.Out)
if err != nil {
return "", err
}
return string(password), nil
}
func (p *system) Confirm(prompt string, defaultYes bool) (bool, error) {
r, err := p.reader()
if err != nil {