diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..67c849e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.worktrees +bin/ +dist/ +cover.out +*.md +docs/ +.github/ +Dockerfile +.dockerignore +.goreleaser.yml +Makefile +*.test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78443d9..dc9c94f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: run: go mod download - name: Run tests - run: go test -v -race -coverprofile=coverage.out ./... + run: go test -v ./... - name: Run vet run: go vet ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c2cc5c..11dd937 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,3 +34,4 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 09a3158..380ae85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .worktrees/ bin/ dist/ +bb diff --git a/.goreleaser.yml b/.goreleaser.yml index 9a1990a..9c479ce 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -23,8 +23,8 @@ builds: - arm64 ldflags: - -s -w - - -X github.com/rbansal42/bb/internal/cmd.Version={{.Version}} - - -X github.com/rbansal42/bb/internal/cmd.BuildDate={{.Date}} + - -X github.com/rbansal42/bitbucket-cli/internal/cmd.Version={{.Version}} + - -X github.com/rbansal42/bitbucket-cli/internal/cmd.BuildDate={{.Date}} archives: - id: default @@ -61,23 +61,26 @@ changelog: release: github: owner: rbansal42 - name: bb + name: bitbucket-cli draft: false prerelease: auto mode: replace name_template: "v{{.Version}}" +# Homebrew tap configuration brews: - name: bb repository: owner: rbansal42 name: homebrew-tap token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" - folder: Formula - homepage: "https://github.com/rbansal42/bb" + directory: Formula + homepage: "https://github.com/rbansal42/bitbucket-cli" description: "Unofficial CLI for Bitbucket Cloud" license: "MIT" install: | bin.install "bb" test: | - system "#{bin}/bb", "version" + system "#{bin}/bb", "--version" + # Skip homebrew publish for pre-release versions + skip_upload: auto diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d01836b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,624 @@ +# AGENTS.md - Guide for LLMs and Autonomous Agents + +This document provides guidance for LLMs (Claude, GPT-4, etc.) and autonomous agents (Devin, OpenHands, Claude Code) to effectively use the `bb` CLI for Bitbucket Cloud operations. + +## Core Concepts + +### What is `bb`? + +`bb` is an unofficial command-line interface for Bitbucket Cloud, similar to GitHub's `gh` CLI. It allows you to interact with Bitbucket repositories, pull requests, issues, pipelines, and more from the terminal. + +### Resource Hierarchy + +Bitbucket resources follow this hierarchy: + +``` +Workspace (e.g., "hudle") +├── Project (e.g., "PROJ") +│ └── Repository (e.g., "backend-api") +│ ├── Pull Requests +│ ├── Issues +│ ├── Pipelines +│ └── Branches +└── Snippets (workspace-level code snippets) +``` + +Understanding this hierarchy is essential: +- **Workspace**: Top-level container (like a GitHub organization) +- **Project**: Optional grouping of repositories within a workspace +- **Repository**: A git repository, always belongs to a workspace + +### Repository Identification + +Repositories are identified in two ways: + +1. **Explicit**: `workspace/repo-name` format + ```bash + bb pr list --repo hudle/backend-api + ``` + +2. **Implicit**: Detected from current git directory's remote + ```bash + cd ~/projects/backend-api + bb pr list # auto-detects from git remote + ``` + +3. **Default workspace**: If a default workspace is configured, you can use just the repo name + ```bash + bb workspace set-default hudle + bb pr list --repo backend-api # resolves to hudle/backend-api + ``` + +### Output Formats + +Most commands support two output formats: + +- **Human-readable** (default): Formatted tables for terminal display +- **JSON** (`--json` flag): Structured output for programmatic parsing + +For programmatic use, always prefer `--json` output: +```bash +bb pr list --json +bb repo list --workspace myworkspace --json +``` + +--- + +## Authentication + +### Checking Authentication Status + +Before performing any operations, verify authentication: + +```bash +bb auth status +``` + +Expected output when authenticated: +``` +bitbucket.org +✓ Logged in to bitbucket.org account username (keyring) + - Active account: true + - Git operations protocol: ssh + - Token: ATAT****...**** +``` + +If not authenticated, the output will indicate no active account. + +### Authentication Methods + +#### Method 1: API Token (Recommended for CI/CD) + +Atlassian API tokens are the simplest method, especially for automation: + +```bash +bb auth login +# Select "API Token" when prompted +# Enter your Atlassian account email +# Paste your API token from https://id.atlassian.com/manage-profile/security/api-tokens +``` + +#### Method 2: OAuth (Interactive) + +OAuth is browser-based and supports automatic token refresh: + +```bash +bb auth login +# Select "OAuth" when prompted +# Complete browser-based authorization +``` + +### Environment Variables for CI/CD + +For automated environments, set credentials via environment variables: + +| Variable | Description | +|----------|-------------| +| `BB_TOKEN` | API token or OAuth access token | +| `BB_USERNAME` | Atlassian account email (for API token auth) | + +Example CI/CD setup: +```bash +export BB_USERNAME="ci-bot@company.com" +export BB_TOKEN="ATATT3xFfGF0..." +bb pr list --repo myworkspace/myrepo +``` + +### Authentication Precedence + +The CLI checks for credentials in this order: +1. Environment variables (`BB_TOKEN`, `BB_USERNAME`) +2. System keyring (stored by `bb auth login`) +3. Config file credentials + +### Re-authentication + +If authentication expires or becomes invalid: + +```bash +bb auth logout +bb auth login +``` + +--- + +## Command Reference by Category + +### Workspace Commands + +```bash +# List workspaces you have access to +bb workspace list + +# View workspace details +bb workspace view + +# List workspace members +bb workspace members + +# Set default workspace (eliminates need for --workspace flag) +bb workspace set-default + +# Show current default workspace +bb workspace set-default +``` + +### Repository Commands + +```bash +# List repositories in a workspace +bb repo list --workspace +bb repo list # uses default workspace + +# View repository details +bb repo view +bb repo view # uses current git directory + +# Clone a repository +bb repo clone [directory] + +# Create a new repository +bb repo create --name --workspace +bb repo create --name # uses default workspace + +# Delete a repository (requires confirmation) +bb repo delete + +# Fork a repository +bb repo fork --workspace + +# Sync fork with upstream +bb repo sync +``` + +### Pull Request Commands + +```bash +# List pull requests +bb pr list --repo +bb pr list --state open|merged|declined|superseded +bb pr list # uses current git directory + +# View pull request details +bb pr view --repo +bb pr view # uses current git directory + +# Create a pull request +bb pr create --title "Title" --source --destination +bb pr create # interactive mode + +# Edit a pull request +bb pr edit --title "New Title" + +# Merge a pull request +bb pr merge +bb pr merge --strategy squash|merge-commit|fast-forward + +# Close/decline a pull request +bb pr close + +# Reopen a declined pull request +bb pr reopen + +# Checkout PR branch locally +bb pr checkout + +# View PR diff +bb pr diff + +# Check PR pipeline status +bb pr checks + +# Add comment to PR +bb pr comment --body "Comment text" + +# Review a PR (approve/request-changes/unapprove) +bb pr review --approve +bb pr review --request-changes --body "Please fix..." +``` + +### Pipeline Commands + +```bash +# List pipelines +bb pipeline list --repo + +# View pipeline details +bb pipeline view + +# Run a pipeline +bb pipeline run --branch +bb pipeline run --branch --custom + +# Stop a running pipeline +bb pipeline stop + +# View pipeline step details +bb pipeline steps + +# View pipeline logs +bb pipeline logs +bb pipeline logs --step +``` + +### Issue Commands + +```bash +# List issues +bb issue list --repo +bb issue list --state open|closed|new|on-hold + +# View issue details +bb issue view + +# Create an issue +bb issue create --title "Title" --kind bug|enhancement|proposal|task +bb issue create --title "Title" --priority trivial|minor|major|critical|blocker + +# Edit an issue +bb issue edit --title "New Title" +bb issue edit --assignee + +# Close an issue +bb issue close + +# Reopen an issue +bb issue reopen + +# Delete an issue +bb issue delete + +# Comment on an issue +bb issue comment --body "Comment text" +``` + +### Branch Commands + +```bash +# List branches +bb branch list --repo + +# Create a branch +bb branch create --target + +# Delete a branch +bb branch delete +``` + +### Project Commands + +```bash +# List projects in a workspace +bb project list --workspace + +# View project details +bb project view --workspace + +# Create a project +bb project create --key --name "Project Name" --workspace +``` + +### Snippet Commands + +```bash +# List snippets +bb snippet list --workspace + +# View a snippet +bb snippet view --workspace + +# Create a snippet +bb snippet create --title "Title" --file --workspace + +# Edit a snippet +bb snippet edit --title "New Title" --workspace + +# Delete a snippet +bb snippet delete --workspace +``` + +### Utility Commands + +```bash +# Open repository in browser +bb browse +bb browse --repo + +# Raw API requests +bb api +bb api /repositories/workspace/repo +bb api /user + +# Configuration +bb config list +bb config get +bb config set +``` + +--- + +## Common Errors and Recovery + +### Authentication Errors + +#### Error: "not logged in" +``` +Error: not logged in to any Bitbucket account +``` +**Cause:** No authentication credentials found. +**Recovery:** +```bash +bb auth login +``` + +#### Error: "Token is invalid, expired, or not supported" +``` +API error 401: Token is invalid, expired, or not supported for this endpoint. +``` +**Cause:** Authentication token has expired or is invalid. +**Recovery:** +```bash +bb auth logout +bb auth login +``` + +#### Error: "Authentication failed" +``` +Error: authentication failed +``` +**Cause:** Invalid credentials or token. +**Recovery:** +1. Verify your API token is correct +2. Check if token has been revoked at https://id.atlassian.com/manage-profile/security/api-tokens +3. Re-authenticate with `bb auth login` + +### Permission Errors + +#### Error: "You do not have access to view this workspace" +``` +API error 403: You do not have access to view this workspace. +``` +**Cause:** Insufficient permissions for the requested operation. This often occurs when trying to create/delete resources in a workspace where you only have read access. +**Recovery:** +1. Check your role in the workspace: + ```bash + bb workspace list + ``` +2. If you're a "member", you may need "contributor" or "admin" role for write operations +3. Contact a workspace admin to request elevated permissions + +#### Error: "Repository not found" (but it exists) +``` +API error 404: Repository not found +``` +**Cause:** You don't have access to this private repository, or the workspace/repo name is incorrect. +**Recovery:** +1. Verify the repository name and workspace: + ```bash + bb repo list --workspace + ``` +2. Check if you have access to the repository +3. Verify spelling of workspace and repository names + +### Repository Resolution Errors + +#### Error: "invalid repository format" +``` +Error: invalid repository format: myrepo (expected workspace/repo) +``` +**Cause:** Repository specified without workspace, and no default workspace is configured. +**Recovery:** +```bash +# Option 1: Use full format +bb pr list --repo workspace/myrepo + +# Option 2: Set a default workspace +bb workspace set-default +bb pr list --repo myrepo +``` + +#### Error: "could not detect repository" +``` +Error: could not detect repository: not a git repository +``` +**Cause:** Command run outside a git repository and no `--repo` flag provided. +**Recovery:** +```bash +# Option 1: Specify repository explicitly +bb pr list --repo workspace/repo + +# Option 2: Navigate to a git repository +cd /path/to/repo +bb pr list +``` + +### Rate Limiting + +#### Error: "Rate limit exceeded" +``` +API error 429: Rate limit exceeded +``` +**Cause:** Too many API requests in a short period. +**Recovery:** +1. Wait a few minutes before retrying +2. Reduce frequency of API calls +3. Use `--json` output and cache results when possible + +### Pipeline Errors + +#### Error: "Pipeline not found" +``` +API error 404: Pipeline not found +``` +**Cause:** The pipeline ID doesn't exist or has been deleted. +**Recovery:** +```bash +# List available pipelines +bb pipeline list --repo workspace/repo +``` + +#### Error: "Cannot run pipeline" +``` +Error: cannot run pipeline: no bitbucket-pipelines.yml found +``` +**Cause:** Repository doesn't have pipelines configured. +**Recovery:** +1. Ensure `bitbucket-pipelines.yml` exists in the repository root +2. Verify pipelines are enabled in repository settings + +--- + +## Best Practices for Agents + +### 1. Always Verify Authentication First + +Before performing any operations, check authentication status: +```bash +bb auth status +``` + +If not authenticated, handle gracefully by informing the user or attempting to authenticate if credentials are available. + +### 2. Use Explicit Repository References + +While auto-detection from git remotes is convenient, explicit references are more reliable: +```bash +# Prefer explicit +bb pr list --repo workspace/repo + +# Over implicit (may fail if not in git directory) +bb pr list +``` + +### 3. Set Default Workspace for Repeated Operations + +When performing multiple operations on the same workspace: +```bash +bb workspace set-default myworkspace +# Subsequent commands don't need --workspace +bb repo list +bb project list +``` + +### 4. Use JSON Output for Parsing + +Always use `--json` flag when you need to process command output: +```bash +bb pr list --json +bb repo view workspace/repo --json +``` + +### 5. Check Before Destructive Operations + +Before delete or merge operations, verify the resource exists: +```bash +# Check PR exists and is open before merging +bb pr view 123 --json + +# Then merge +bb pr merge 123 +``` + +### 6. Handle Errors Gracefully + +Parse error messages to determine the appropriate recovery action: +- 401 errors: Re-authenticate +- 403 errors: Permission issue - inform user +- 404 errors: Resource not found - verify names +- 429 errors: Rate limited - wait and retry + +### 7. Use Appropriate Flags for Non-Interactive Mode + +For automated scripts and CI/CD, use flags that avoid interactive prompts: +```bash +# Force flag for destructive operations +bb repo delete workspace/repo --force +bb snippet delete abc123 --workspace ws --force + +# Specify all required information via flags +bb pr create --title "Title" --source feature --destination main --body "Description" +``` + +### 8. Respect API Rate Limits + +Bitbucket has API rate limits. When making multiple requests: +- Add small delays between requests if making many calls +- Cache responses when appropriate +- Use list commands with `--limit` to reduce data transfer + +### 9. Validate Inputs Before Commands + +Before running commands, validate that required information is available: +- Workspace name is valid +- Repository exists +- PR/Issue numbers are numeric +- Branch names are valid + +### 10. Provide Clear Feedback + +When using `bb` as part of a larger workflow, capture and relay command output to provide clear feedback about what operations were performed and their results. + +--- + +## Configuration Reference + +### Config File Location + +``` +~/.config/bb/config.yml +``` + +### Available Settings + +| Setting | Description | Default | +|---------|-------------|---------| +| `git_protocol` | Protocol for git operations (`ssh` or `https`) | `ssh` | +| `editor` | Editor for interactive input | `$EDITOR` or `vim` | +| `prompt` | Enable/disable prompts | `enabled` | +| `pager` | Pager for long output | `$PAGER` or `less` | +| `browser` | Browser for web commands | system default | +| `http_timeout` | API request timeout (seconds) | `30` | +| `default_workspace` | Default workspace for commands | none | + +### Setting Configuration + +```bash +bb config set git_protocol https +bb config set default_workspace myworkspace +bb config get default_workspace +bb config list +``` + +--- + +## Version and Help + +```bash +# Show version +bb --version + +# Show help +bb --help +bb --help +bb --help +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e653912 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,293 @@ +# Contributing to bb CLI + +Thank you for your interest in contributing to `bb`, the Bitbucket Cloud CLI! This document provides guidelines and instructions for contributing. + +## Code of Conduct + +Please read and follow our [Code of Conduct](CODE_OF_CONDUCT.md) to help maintain a welcoming and inclusive community. + +## Development Setup + +### Prerequisites + +- **Go 1.21+** - [Download and install Go](https://go.dev/dl/) +- **Git** - For version control + +### Getting Started + +1. **Fork the repository** on GitHub + +2. **Clone your fork:** + ```bash + git clone https://github.com/YOUR_USERNAME/bitbucket-cli.git + cd bitbucket-cli + ``` + +3. **Build the CLI:** + ```bash + go build ./cmd/bb + ``` + +4. **Run tests:** + ```bash + go test ./... + ``` + +5. **Run the CLI locally:** + ```bash + ./bb --help + ``` + +## Project Structure + +``` +bitbucket-cli/ +├── cmd/bb/ # Main entry point +│ └── main.go # Application bootstrap +├── internal/ +│ ├── api/ # Bitbucket API client +│ ├── cmd/ # Command implementations +│ ├── cmdutil/ # Shared command utilities +│ └── config/ # Configuration handling +├── go.mod +└── go.sum +``` + +| Directory | Purpose | +|-----------|---------| +| `cmd/bb/` | Main entry point and CLI initialization | +| `internal/api/` | Bitbucket Cloud API client and types | +| `internal/cmd/` | Individual command implementations (pr, repo, etc.) | +| `internal/config/` | Configuration loading, storage, and authentication | +| `internal/cmdutil/` | Shared utilities for commands (formatting, prompts, etc.) | + +## Adding New Commands + +We use [Cobra](https://github.com/spf13/cobra) for command management. To add a new command: + +1. **Create a new file** in `internal/cmd/` (or appropriate subdirectory): + + ```go + // internal/cmd/mycommand/mycommand.go + package mycommand + + import ( + "github.com/spf13/cobra" + ) + + func NewCmdMyCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "mycommand", + Short: "Brief description of the command", + Long: `Longer description explaining the command in detail.`, + RunE: func(cmd *cobra.Command, args []string) error { + // Command implementation + return nil + }, + } + + // Add flags + cmd.Flags().StringP("flag-name", "f", "", "Flag description") + + return cmd + } + ``` + +2. **Register the command** in the parent command or root command. + +3. **Add tests** for your command in a `_test.go` file. + +## Code Style Guidelines + +### Formatting + +- Run `gofmt` on all code before committing: + ```bash + gofmt -w . + ``` + +- Run `go vet` to catch common issues: + ```bash + go vet ./... + ``` + +### Best Practices + +- Follow [Effective Go](https://go.dev/doc/effective_go) guidelines +- Keep functions focused and small +- Use meaningful variable and function names +- Add comments for exported functions and types +- Handle errors explicitly; avoid ignoring them + +### Linting (Optional) + +We recommend using `golangci-lint` for comprehensive linting: +```bash +golangci-lint run +``` + +## Testing Requirements + +- **All new features must include tests** +- **Bug fixes should include regression tests** +- Tests should be in `_test.go` files alongside the code they test + +### Running Tests + +```bash +# Run all tests +go test ./... + +# Run tests with verbose output +go test -v ./... + +# Run tests with coverage +go test -cover ./... + +# Run tests for a specific package +go test ./internal/cmd/pr/... +``` + +### Writing Tests + +```go +func TestMyFunction(t *testing.T) { + // Arrange + input := "test" + + // Act + result := MyFunction(input) + + // Assert + if result != expected { + t.Errorf("MyFunction(%q) = %q; want %q", input, result, expected) + } +} +``` + +## Pull Request Process + +### Before You Start + +1. Check existing issues and PRs to avoid duplicate work +2. For significant changes, open an issue first to discuss the approach + +### Creating a Pull Request + +1. **Create a feature branch:** + ```bash + git checkout -b feature/your-feature-name + # or + git checkout -b fix/issue-description + ``` + +2. **Make your changes** with clear, focused commits + +3. **Commit message format:** + ``` + type: short description + + Longer description if needed. Explain what and why, + not how (the code shows how). + + Fixes #123 + ``` + + Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore` + + Examples: + ``` + feat: add support for PR templates + + fix: handle empty repository list gracefully + + docs: update installation instructions + ``` + +4. **Push your branch:** + ```bash + git push origin feature/your-feature-name + ``` + +5. **Open a Pull Request** with the following information: + + ```markdown + ## Description + Brief description of the changes. + + ## Type of Change + - [ ] Bug fix + - [ ] New feature + - [ ] Breaking change + - [ ] Documentation update + + ## Testing + Describe how you tested your changes. + + ## Checklist + - [ ] Code follows the project's style guidelines + - [ ] Tests added/updated for changes + - [ ] Documentation updated if needed + - [ ] `go test ./...` passes + - [ ] `go vet ./...` reports no issues + ``` + +### Review Process + +- PRs require at least one maintainer approval +- Address review feedback promptly +- Keep PRs focused; split large changes into smaller PRs + +## Issue Reporting Guidelines + +### Bug Reports + +When reporting a bug, include: + +- **bb version:** Output of `bb --version` +- **Go version:** Output of `go version` +- **Operating system:** e.g., macOS 14.0, Ubuntu 22.04 +- **Steps to reproduce:** Clear, numbered steps +- **Expected behavior:** What you expected to happen +- **Actual behavior:** What actually happened +- **Error messages:** Full error output if applicable + +### Feature Requests + +When requesting a feature, include: + +- **Use case:** Why do you need this feature? +- **Proposed solution:** How do you envision it working? +- **Alternatives considered:** Other approaches you've thought about + +## Release Process (Maintainers) + +1. **Update version** in relevant files + +2. **Update CHANGELOG.md** with release notes + +3. **Create a release commit:** + ```bash + git commit -am "chore: release vX.Y.Z" + ``` + +4. **Tag the release:** + ```bash + git tag -a vX.Y.Z -m "Release vX.Y.Z" + git push origin main --tags + ``` + +5. **Create GitHub release** with changelog notes + +6. **Verify** release artifacts are built and published correctly + +--- + +## Questions? + +If you have questions about contributing, feel free to: + +- Open a [Discussion](https://github.com/YOUR_ORG/bitbucket-cli/discussions) +- Ask in an existing related issue + +Thank you for contributing! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..43a2a40 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +ARG BUILD_DATE=unknown + +RUN CGO_ENABLED=0 go build \ + -ldflags "-s -w -X github.com/rbansal42/bitbucket-cli/internal/cmd.Version=${VERSION} -X github.com/rbansal42/bitbucket-cli/internal/cmd.BuildDate=${BUILD_DATE}" \ + -o /bin/bb ./cmd/bb + +# Runtime stage +FROM alpine:3.21 + +LABEL org.opencontainers.image.source="https://github.com/rbansal42/bitbucket-cli" +LABEL org.opencontainers.image.description="Unofficial CLI for Bitbucket Cloud" + +RUN apk add --no-cache git ca-certificates && \ + adduser -D -h /home/bb bb + +USER bb + +COPY --from=builder /bin/bb /usr/local/bin/bb + +ENTRYPOINT ["bb"] diff --git a/README.md b/README.md index 73f1987..26d9f4b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,22 @@ An unofficial command-line interface for Bitbucket Cloud, inspired by GitHub's `gh` CLI. +[![CI](https://github.com/rbansal42/bitbucket-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/rbansal42/bitbucket-cli/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/rbansal42/bitbucket-cli)](https://github.com/rbansal42/bitbucket-cli/releases) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Features + +- **Pull Requests**: Create, list, view, merge, review, and manage PRs +- **Repositories**: Clone, create, fork, and manage repositories +- **Issues**: Create, list, view, and manage issue tracker issues +- **Pipelines**: Trigger, monitor, and view CI/CD pipeline logs +- **Branches**: Create, list, and delete branches +- **Workspaces & Projects**: Browse and manage Bitbucket workspaces and projects +- **Snippets**: Create and manage code snippets +- **Authentication**: Secure OAuth and access token support +- **Shell Completions**: Tab completion for Bash, Zsh, Fish, and PowerShell + ## Installation ### Homebrew (macOS and Linux) @@ -12,122 +28,356 @@ brew install rbansal42/tap/bb ### Download Binary -Download the latest release from the [releases page](https://github.com/rbansal42/bb/releases). +Download the latest release from the [releases page](https://github.com/rbansal42/bitbucket-cli/releases). + +Available for: +- macOS (Intel and Apple Silicon) +- Linux (amd64, arm64) +- Windows (amd64) ### Build from Source ```bash -go install github.com/rbansal42/bb/cmd/bb@latest +go install github.com/rbansal42/bitbucket-cli/cmd/bb@latest +``` + +Or clone and build: + +```bash +git clone https://github.com/rbansal42/bitbucket-cli.git +cd bb +go build -o bb ./cmd/bb ``` ## Quick Start -1. **Authenticate with Bitbucket:** - ```bash - bb auth login - ``` +### 1. Authenticate with Bitbucket + +```bash +bb auth login +``` + +This will guide you through OAuth authentication or access token setup. + +### 2. Clone a Repository + +```bash +bb repo clone myworkspace/myrepo +``` + +### 3. Work with Pull Requests + +```bash +# List open PRs in current repo +bb pr list + +# Create a new PR +bb pr create --title "Add new feature" --base main + +# View PR details +bb pr view 123 + +# Checkout a PR locally +bb pr checkout 123 + +# Merge a PR +bb pr merge 123 +``` + +## Common Workflows + +### Pull Request Workflow + +```bash +# Create a feature branch and make changes +git checkout -b feature/my-feature +# ... make changes ... +git commit -am "Add my feature" +git push -u origin feature/my-feature + +# Create a pull request +bb pr create --title "Add my feature" --body "Description of changes" + +# After review, merge the PR +bb pr merge 123 --merge-strategy squash --delete-branch +``` + +### Code Review Workflow + +```bash +# List PRs assigned to you for review +bb pr list --reviewer @me + +# View PR details and diff +bb pr view 123 +bb pr diff 123 -2. **Start using commands:** - ```bash - bb pr list - bb repo clone workspace/repo - bb issue create - ``` +# Approve or request changes +bb pr review 123 --approve +bb pr review 123 --request-changes --body "Please fix the tests" + +# Add a comment +bb pr comment 123 --body "Looks good, just one suggestion..." +``` + +### CI/CD Pipeline Workflow + +```bash +# Trigger a pipeline on current branch +bb pipeline run + +# Trigger a pipeline on a specific branch +bb pipeline run --branch develop + +# List recent pipelines +bb pipeline list + +# View pipeline details +bb pipeline view + +# Watch pipeline logs +bb pipeline logs + +# Stop a running pipeline +bb pipeline stop +``` -## Commands +### Issue Tracking Workflow + +```bash +# Create an issue +bb issue create --title "Bug: Login fails" --kind bug --priority critical + +# List open issues +bb issue list --state open + +# View and comment on an issue +bb issue view 42 +bb issue comment 42 --body "I can reproduce this on v2.0" + +# Close an issue +bb issue close 42 +``` + +### Repository Management + +```bash +# List your repositories +bb repo list + +# Create a new repository +bb repo create --name my-new-repo --private --description "My project" + +# Fork a repository +bb repo fork otherworkspace/cool-project + +# View repository details +bb repo view + +# Open repository in browser +bb browse +``` + +## Commands Reference ### Authentication -- `bb auth login` - Authenticate with Bitbucket -- `bb auth logout` - Log out of Bitbucket -- `bb auth status` - View authentication status +| Command | Description | +|---------|-------------| +| `bb auth login` | Authenticate with Bitbucket | +| `bb auth logout` | Log out of Bitbucket | +| `bb auth status` | View authentication status | ### Pull Requests -- `bb pr list` - List pull requests -- `bb pr view ` - View a pull request -- `bb pr create` - Create a pull request -- `bb pr merge ` - Merge a pull request -- `bb pr checkout ` - Checkout a pull request -- `bb pr close ` - Close a pull request -- `bb pr approve ` - Approve a pull request -- `bb pr diff ` - View pull request diff +| Command | Description | +|---------|-------------| +| `bb pr list` | List pull requests | +| `bb pr view ` | View a pull request | +| `bb pr create` | Create a pull request | +| `bb pr merge ` | Merge a pull request | +| `bb pr checkout ` | Checkout a PR branch locally | +| `bb pr close ` | Decline/close a pull request | +| `bb pr reopen ` | Reopen a declined pull request | +| `bb pr edit ` | Edit PR title, description, or base | +| `bb pr review ` | Add a review (approve/request-changes) | +| `bb pr comment ` | Add a comment to a PR | +| `bb pr diff ` | View pull request diff | +| `bb pr checks ` | View CI/CD status checks | ### Repositories -- `bb repo list` - List repositories -- `bb repo view` - View repository details -- `bb repo clone ` - Clone a repository -- `bb repo create` - Create a repository -- `bb repo fork ` - Fork a repository +| Command | Description | +|---------|-------------| +| `bb repo list` | List repositories | +| `bb repo view` | View repository details | +| `bb repo clone ` | Clone a repository | +| `bb repo create` | Create a new repository | +| `bb repo fork ` | Fork a repository | +| `bb repo delete ` | Delete a repository | +| `bb repo sync` | Sync fork with upstream | +| `bb repo set-default` | Set default repository for current directory | ### Issues -- `bb issue list` - List issues -- `bb issue view ` - View an issue -- `bb issue create` - Create an issue -- `bb issue close ` - Close an issue -- `bb issue comment ` - Comment on an issue +| Command | Description | +|---------|-------------| +| `bb issue list` | List issues | +| `bb issue view ` | View an issue | +| `bb issue create` | Create an issue | +| `bb issue edit ` | Edit an issue | +| `bb issue close ` | Close/resolve an issue | +| `bb issue reopen ` | Reopen an issue | +| `bb issue comment ` | Add a comment to an issue | +| `bb issue delete ` | Delete an issue | ### Pipelines -- `bb pipeline list` - List pipelines -- `bb pipeline view ` - View pipeline details -- `bb pipeline run` - Trigger a pipeline -- `bb pipeline logs ` - View pipeline logs -- `bb pipeline stop ` - Stop a running pipeline +| Command | Description | +|---------|-------------| +| `bb pipeline list` | List pipelines | +| `bb pipeline view ` | View pipeline details | +| `bb pipeline run` | Trigger a pipeline | +| `bb pipeline logs ` | View pipeline logs | +| `bb pipeline steps ` | View pipeline steps | +| `bb pipeline stop ` | Stop a running pipeline | ### Branches -- `bb branch list` - List branches -- `bb branch create ` - Create a branch -- `bb branch delete ` - Delete a branch +| Command | Description | +|---------|-------------| +| `bb branch list` | List branches | +| `bb branch create ` | Create a branch | +| `bb branch delete ` | Delete a branch | ### Workspaces -- `bb workspace list` - List workspaces -- `bb workspace view ` - View workspace details -- `bb workspace members ` - List workspace members +| Command | Description | +|---------|-------------| +| `bb workspace list` | List workspaces | +| `bb workspace view ` | View workspace details | +| `bb workspace members ` | List workspace members | ### Projects -- `bb project list` - List projects -- `bb project view ` - View project details -- `bb project create` - Create a project +| Command | Description | +|---------|-------------| +| `bb project list` | List projects | +| `bb project view ` | View project details | +| `bb project create` | Create a project | ### Snippets -- `bb snippet list` - List snippets -- `bb snippet view ` - View a snippet -- `bb snippet create` - Create a snippet -- `bb snippet edit ` - Edit a snippet -- `bb snippet delete ` - Delete a snippet - -### Other -- `bb browse` - Open repository in browser -- `bb api ` - Make API requests -- `bb config` - Manage configuration -- `bb completion` - Generate shell completions +| Command | Description | +|---------|-------------| +| `bb snippet list` | List snippets | +| `bb snippet view ` | View a snippet | +| `bb snippet create` | Create a snippet | +| `bb snippet edit ` | Edit a snippet | +| `bb snippet delete ` | Delete a snippet | + +### Other Commands +| Command | Description | +|---------|-------------| +| `bb browse` | Open repository in browser | +| `bb api ` | Make raw API requests | +| `bb config get/set` | Manage configuration | +| `bb completion ` | Generate shell completions | ## Shell Completion -Generate completions for your shell: +Enable tab completion for your shell: + +### Bash ```bash -# Bash -bb completion bash > /etc/bash_completion.d/bb +# Linux +bb completion bash | sudo tee /etc/bash_completion.d/bb > /dev/null + +# macOS (with Homebrew bash-completion) +bb completion bash > $(brew --prefix)/etc/bash_completion.d/bb +``` + +### Zsh + +```bash +# If shell completion is not already enabled, add this to ~/.zshrc: +# autoload -Uz compinit && compinit -# Zsh bb completion zsh > "${fpath[1]}/_bb" +``` -# Fish +### Fish + +```bash bb completion fish > ~/.config/fish/completions/bb.fish +``` -# PowerShell +### PowerShell + +```powershell bb completion powershell | Out-String | Invoke-Expression + +# To load on startup, add to your profile: +# bb completion powershell | Out-String | Invoke-Expression ``` ## Configuration -Configuration is stored in `~/.config/bb/` (or `$XDG_CONFIG_HOME/bb/`). +Configuration files are stored in `~/.config/bb/` (or `$XDG_CONFIG_HOME/bb/` on Linux). + +### Files + +| File | Description | +|------|-------------| +| `hosts.yml` | Authentication tokens per host | +| `config.yml` | General settings | + +### Settings + +```bash +# Set preferred git protocol (https or ssh) +bb config set git_protocol ssh + +# Set default editor +bb config set editor vim + +# View current configuration +bb config get git_protocol +``` + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `BB_TOKEN` | Override authentication token | +| `BITBUCKET_TOKEN` | Alternative token variable | +| `BB_REPO` | Override repository (workspace/repo) | +| `NO_COLOR` | Disable colored output | + +## Comparison with gh CLI + +`bb` is designed to feel familiar to `gh` CLI users: + +| gh command | bb equivalent | +|------------|---------------| +| `gh auth login` | `bb auth login` | +| `gh pr list` | `bb pr list` | +| `gh pr create` | `bb pr create` | +| `gh repo clone` | `bb repo clone` | +| `gh issue create` | `bb issue create` | +| `gh run list` | `bb pipeline list` | +| `gh api` | `bb api` | + +### Key Differences + +- **Workspaces**: Bitbucket uses workspaces instead of organizations +- **Pipelines**: Bitbucket Pipelines vs GitHub Actions +- **Issue Tracker**: Bitbucket's built-in issue tracker (when enabled) +- **Snippets**: Bitbucket snippets vs GitHub Gists + +## Documentation -- `hosts.yml` - Authentication tokens and host configuration -- `config.yml` - General settings +- [Authentication Guide](docs/guide/authentication.md) +- [Configuration Guide](docs/guide/configuration.md) +- [Scripting & Automation](docs/guide/scripting.md) +- [Troubleshooting](docs/guide/troubleshooting.md) +- [Command Reference](docs/commands/) ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. ## License diff --git a/cmd/bb/main.go b/cmd/bb/main.go index 603ef5a..158ae8c 100644 --- a/cmd/bb/main.go +++ b/cmd/bb/main.go @@ -3,7 +3,7 @@ package main import ( "os" - "github.com/rbansal42/bb/internal/cmd" + "github.com/rbansal42/bitbucket-cli/internal/cmd" ) func main() { diff --git a/docs/commands/bb_auth.md b/docs/commands/bb_auth.md new file mode 100644 index 0000000..3ef2d3a --- /dev/null +++ b/docs/commands/bb_auth.md @@ -0,0 +1,178 @@ +# bb auth + +Authenticate bb with Bitbucket Cloud. + +## Synopsis + +``` +bb auth [flags] +``` + +## Description + +Manage authentication state for bb CLI. This includes logging in, logging out, and checking your current authentication status. + +## Subcommands + +- [bb auth login](#bb-auth-login) - Authenticate with Bitbucket +- [bb auth logout](#bb-auth-logout) - Log out of Bitbucket +- [bb auth status](#bb-auth-status) - View authentication status + +--- + +# bb auth login + +Authenticate with Bitbucket Cloud. + +## Synopsis + +``` +bb auth login [flags] +``` + +## Description + +Authenticate with Bitbucket Cloud using either OAuth 2.0 or a Repository Access Token. + +By default, `bb auth login` starts an OAuth flow that opens your browser to complete authentication. This requires setting up an OAuth consumer first (see Authentication Guide). + +Alternatively, you can authenticate using a Repository Access Token by passing the `--with-token` flag. Repository Access Tokens can be created in your repository settings under **Repository settings > Access tokens**. + +The authentication token is stored securely in your system's credential store when available, or in a local configuration file. + +## Flags + +| Flag | Description | +|------|-------------| +| `--with-token` | Read token from standard input instead of using OAuth flow | +| `-w, --workspace ` | Set default workspace after login | +| `--scopes ` | Comma-separated list of OAuth scopes to request (OAuth flow only) | +| `-h, --help` | Show help for command | + +## Examples + +Authenticate interactively via OAuth: + +``` +$ bb auth login +``` + +Authenticate using a Repository Access Token: + +``` +$ bb auth login --with-token < token.txt +``` + +``` +$ echo "$BITBUCKET_TOKEN" | bb auth login --with-token +``` + +Authenticate and set a default workspace: + +``` +$ bb auth login -w myworkspace +``` + +Request specific OAuth scopes: + +``` +$ bb auth login --scopes repository,pullrequest:write +``` + +## See also + +- [bb auth logout](#bb-auth-logout) - Log out of Bitbucket +- [bb auth status](#bb-auth-status) - View authentication status + +--- + +# bb auth logout + +Log out of Bitbucket Cloud. + +## Synopsis + +``` +bb auth logout [flags] +``` + +## Description + +Remove authentication credentials for Bitbucket Cloud from the local system. + +This command removes the stored access token and any associated credentials from your system's credential store or configuration file. After logging out, you will need to run `bb auth login` again to use authenticated commands. + +## Flags + +| Flag | Description | +|------|-------------| +| `-h, --help` | Show help for command | + +## Examples + +Log out of Bitbucket: + +``` +$ bb auth logout +Logged out of Bitbucket Cloud +``` + +## See also + +- [bb auth login](#bb-auth-login) - Authenticate with Bitbucket +- [bb auth status](#bb-auth-status) - View authentication status + +--- + +# bb auth status + +View authentication status. + +## Synopsis + +``` +bb auth status [flags] +``` + +## Description + +Display the current authentication state for the bb CLI. + +Shows whether you are logged in, the username of the authenticated account, the associated workspace(s), and the validity of the stored token. + +If the token has expired or is invalid, you will be prompted to re-authenticate using `bb auth login`. + +## Flags + +| Flag | Description | +|------|-------------| +| `-t, --show-token` | Display the authentication token | +| `-h, --help` | Show help for command | + +## Examples + +Check authentication status: + +``` +$ bb auth status +Bitbucket Cloud + Logged in as: johndoe + Username: johndoe + Workspaces: myteam, personal + Token valid: true + Token expires: 2026-02-06 10:30:00 UTC +``` + +Show the authentication token: + +``` +$ bb auth status --show-token +Bitbucket Cloud + Logged in as: johndoe + Token: ****************************************abc123 +``` + +## See also + +- [bb auth login](#bb-auth-login) - Authenticate with Bitbucket +- [bb auth logout](#bb-auth-logout) - Log out of Bitbucket diff --git a/docs/commands/bb_branch.md b/docs/commands/bb_branch.md new file mode 100644 index 0000000..97a94ea --- /dev/null +++ b/docs/commands/bb_branch.md @@ -0,0 +1,208 @@ +# bb branch + +Manage repository branches. + +## Synopsis + +``` +bb branch [flags] +``` + +## Description + +Create, list, and manage branches in a Bitbucket repository. These commands allow you to work with branches directly from the command line. + +## Subcommands + +- [bb branch list](#bb-branch-list) - List branches +- [bb branch create](#bb-branch-create) - Create a new branch +- [bb branch delete](#bb-branch-delete) - Delete a branch + +--- + +# bb branch list + +List branches in a repository. + +## Synopsis + +``` +bb branch list [flags] +``` + +## Description + +Display a list of branches in the current or specified repository. By default, branches are sorted by most recent commit. + +The default branch is indicated with an asterisk (*) and highlighted when viewing in a terminal. + +## Flags + +| Flag | Description | +|------|-------------| +| `-R, --repo ` | Select a repository (default: current repository) | +| `-s, --sort ` | Sort by field: name, date (default: date) | +| `-f, --filter ` | Filter branches by name pattern | +| `-L, --limit ` | Maximum number of branches to list (default: 30) | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +List branches: + +``` +$ bb branch list +* main abc1234 Fix auth bug 2026-02-05 + feature/api def5678 Add new endpoint 2026-02-04 + develop ghi9012 Merge feature branch 2026-02-03 + hotfix/login jkl3456 Emergency login fix 2026-02-01 +``` + +Filter branches by pattern: + +``` +$ bb branch list --filter "feature/*" + feature/api def5678 Add new endpoint 2026-02-04 + feature/ui mno7890 Update dashboard 2026-02-02 + feature/reports pqr1234 Add export feature 2026-01-30 +``` + +Sort by name: + +``` +$ bb branch list --sort name +``` + +List branches for a specific repository: + +``` +$ bb branch list -R myworkspace/myrepo +``` + +## See also + +- [bb branch create](#bb-branch-create) - Create a new branch +- [bb branch delete](#bb-branch-delete) - Delete a branch + +--- + +# bb branch create + +Create a new branch. + +## Synopsis + +``` +bb branch create [flags] +``` + +## Description + +Create a new branch in the repository. By default, the branch is created from the current HEAD commit. Use the `--target` flag to specify a different starting point. + +The new branch is created remotely on Bitbucket. Use `git fetch` to retrieve it locally. + +## Flags + +| Flag | Description | +|------|-------------| +| `-R, --repo ` | Select a repository (default: current repository) | +| `-t, --target ` | Create branch from this ref (branch name, tag, or commit SHA) | +| `--checkout` | Checkout the new branch locally after creation | +| `-h, --help` | Show help for command | + +## Examples + +Create a branch from current HEAD: + +``` +$ bb branch create feature/new-feature +Created branch 'feature/new-feature' from main (abc1234) +``` + +Create a branch from a specific commit: + +``` +$ bb branch create hotfix/urgent --target abc1234 +Created branch 'hotfix/urgent' from abc1234 +``` + +Create a branch from another branch: + +``` +$ bb branch create release/v2.0 --target develop +Created branch 'release/v2.0' from develop (def5678) +``` + +Create and checkout locally: + +``` +$ bb branch create feature/api --checkout +Created branch 'feature/api' from main (abc1234) +Switched to branch 'feature/api' +``` + +## See also + +- [bb branch list](#bb-branch-list) - List branches +- [bb branch delete](#bb-branch-delete) - Delete a branch + +--- + +# bb branch delete + +Delete a branch. + +## Synopsis + +``` +bb branch delete [flags] +``` + +## Description + +Delete a branch from the remote repository on Bitbucket. + +By default, you will be prompted to confirm deletion. The default branch cannot be deleted. + +If the branch has unmerged changes, you will be warned unless `--force` is specified. + +## Flags + +| Flag | Description | +|------|-------------| +| `-R, --repo ` | Select a repository (default: current repository) | +| `-y, --yes` | Skip confirmation prompt | +| `-f, --force` | Force deletion even if branch has unmerged commits | +| `-h, --help` | Show help for command | + +## Examples + +Delete a branch: + +``` +$ bb branch delete feature/old-feature +? Are you sure you want to delete branch 'feature/old-feature'? Yes +Deleted branch 'feature/old-feature' +``` + +Delete without confirmation: + +``` +$ bb branch delete feature/old-feature --yes +Deleted branch 'feature/old-feature' +``` + +Force delete a branch with unmerged changes: + +``` +$ bb branch delete feature/abandoned --force +Warning: Branch 'feature/abandoned' has 3 unmerged commits +Deleted branch 'feature/abandoned' +``` + +## See also + +- [bb branch list](#bb-branch-list) - List branches +- [bb branch create](#bb-branch-create) - Create a new branch diff --git a/docs/commands/bb_issue.md b/docs/commands/bb_issue.md new file mode 100644 index 0000000..4e72896 --- /dev/null +++ b/docs/commands/bb_issue.md @@ -0,0 +1,541 @@ +# bb issue + +Manage Bitbucket issues. + +## Synopsis + +``` +bb issue [flags] +``` + +## Description + +Create, view, and manage issues in a Bitbucket repository. + +> **Note:** The Bitbucket issue tracker must be enabled on the repository to use these commands. Issue tracking is optional per-repository in Bitbucket Cloud. You can enable it in your repository settings under **Repository settings > Features > Issue tracker**. + +## Subcommands + +- [bb issue list](#bb-issue-list) - List issues +- [bb issue view](#bb-issue-view) - View issue details +- [bb issue create](#bb-issue-create) - Create a new issue +- [bb issue edit](#bb-issue-edit) - Edit an issue +- [bb issue close](#bb-issue-close) - Close an issue +- [bb issue reopen](#bb-issue-reopen) - Reopen an issue +- [bb issue comment](#bb-issue-comment) - Add a comment to an issue +- [bb issue delete](#bb-issue-delete) - Delete an issue + +--- + +# bb issue list + +List issues in a repository. + +## Synopsis + +``` +bb issue list [flags] +``` + +## Description + +List issues in the current repository or a specified repository. By default, displays open issues sorted by most recently updated. + +Results can be filtered by state, kind, priority, and assignee. The output includes the issue ID, title, state, kind, and priority. + +## Flags + +| Flag | Description | +|------|-------------| +| `-s, --state ` | Filter by state: `new`, `open`, `resolved`, `on hold`, `invalid`, `duplicate`, `wontfix`, `closed` | +| `-k, --kind ` | Filter by kind: `bug`, `enhancement`, `proposal`, `task` | +| `-p, --priority ` | Filter by priority: `trivial`, `minor`, `major`, `critical`, `blocker` | +| `-a, --assignee ` | Filter by assignee username | +| `-R, --repo ` | Select repository as `workspace/repo` | +| `-L, --limit ` | Maximum number of issues to list (default 30) | +| `--json` | Output in JSON format | +| `-w, --web` | Open the issue list in browser | +| `-h, --help` | Show help for command | + +## Examples + +List open issues in the current repository: + +``` +$ bb issue list +ID TITLE STATE KIND PRIORITY +#12 Fix login redirect open bug major +#10 Add dark mode support new enhancement minor +#8 Update documentation open task trivial +``` + +List all bugs: + +``` +$ bb issue list --kind bug +``` + +List critical issues assigned to a specific user: + +``` +$ bb issue list --priority critical --assignee johndoe +``` + +List resolved issues: + +``` +$ bb issue list --state resolved +``` + +List issues in a specific repository: + +``` +$ bb issue list -R myworkspace/myrepo +``` + +Output issues as JSON: + +``` +$ bb issue list --json +``` + +## See also + +- [bb issue view](#bb-issue-view) - View issue details +- [bb issue create](#bb-issue-create) - Create a new issue + +--- + +# bb issue view + +View issue details. + +## Synopsis + +``` +bb issue view [flags] +``` + +## Description + +Display the details of a specific issue, including its title, state, kind, priority, description, reporter, assignee, and recent comments. + +The issue ID is the numeric identifier shown in the issue list (e.g., `12` or `#12`). + +## Flags + +| Flag | Description | +|------|-------------| +| `-R, --repo ` | Select repository as `workspace/repo` | +| `-c, --comments` | Show issue comments | +| `--json` | Output in JSON format | +| `-w, --web` | Open the issue in browser | +| `-h, --help` | Show help for command | + +## Examples + +View issue #12: + +``` +$ bb issue view 12 +Fix login redirect +open bug major + +Opened by johndoe on Feb 5, 2026 +Assigned to janedoe + + When users log in, they are redirected to the wrong page. + + Steps to reproduce: + 1. Log out + 2. Navigate to /dashboard + 3. Log in + 4. Observe redirect goes to / instead of /dashboard + +View this issue on Bitbucket: https://bitbucket.org/myworkspace/myrepo/issues/12 +``` + +View issue with comments: + +``` +$ bb issue view 12 --comments +``` + +Open issue in browser: + +``` +$ bb issue view 12 --web +``` + +Output as JSON: + +``` +$ bb issue view 12 --json +``` + +## See also + +- [bb issue list](#bb-issue-list) - List issues +- [bb issue edit](#bb-issue-edit) - Edit an issue +- [bb issue comment](#bb-issue-comment) - Add a comment to an issue + +--- + +# bb issue create + +Create a new issue. + +## Synopsis + +``` +bb issue create [flags] +``` + +## Description + +Create a new issue in the repository. If no flags are provided, an interactive editor will open to compose the issue title and description. + +The issue kind, priority, and assignee can be specified via flags. If not specified, the issue will be created with default values (kind: bug, priority: major). + +## Flags + +| Flag | Description | +|------|-------------| +| `-t, --title ` | Issue title | +| `-b, --body <body>` | Issue description/body | +| `-k, --kind <kind>` | Issue kind: `bug`, `enhancement`, `proposal`, `task` (default: bug) | +| `-p, --priority <priority>` | Issue priority: `trivial`, `minor`, `major`, `critical`, `blocker` (default: major) | +| `-a, --assignee <username>` | Assign issue to a user | +| `-R, --repo <repo>` | Select repository as `workspace/repo` | +| `--json` | Output created issue in JSON format | +| `-w, --web` | Open the created issue in browser | +| `-h, --help` | Show help for command | + +## Examples + +Create an issue interactively: + +``` +$ bb issue create +``` + +Create a bug report with title and description: + +``` +$ bb issue create --title "Login button unresponsive" --body "The login button does not respond to clicks on mobile devices." +``` + +Create an enhancement request: + +``` +$ bb issue create --title "Add export to CSV" --kind enhancement --priority minor +``` + +Create and assign an issue: + +``` +$ bb issue create -t "Database migration failing" -k bug -p critical -a janedoe +``` + +Create an issue in a specific repository: + +``` +$ bb issue create -R myworkspace/myrepo --title "Update README" +``` + +## See also + +- [bb issue list](#bb-issue-list) - List issues +- [bb issue view](#bb-issue-view) - View issue details +- [bb issue edit](#bb-issue-edit) - Edit an issue + +--- + +# bb issue edit + +Edit an existing issue. + +## Synopsis + +``` +bb issue edit <id> [flags] +``` + +## Description + +Edit an existing issue's title, description, kind, priority, or assignee. If no flags are provided, an interactive editor will open with the current issue content. + +Only the fields specified via flags will be updated; other fields remain unchanged. + +## Flags + +| Flag | Description | +|------|-------------| +| `-t, --title <title>` | New issue title | +| `-b, --body <body>` | New issue description/body | +| `-k, --kind <kind>` | New issue kind: `bug`, `enhancement`, `proposal`, `task` | +| `-p, --priority <priority>` | New issue priority: `trivial`, `minor`, `major`, `critical`, `blocker` | +| `-a, --assignee <username>` | Reassign issue to a user | +| `--unassign` | Remove assignee from issue | +| `-R, --repo <repo>` | Select repository as `workspace/repo` | +| `--json` | Output updated issue in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +Edit issue interactively: + +``` +$ bb issue edit 12 +``` + +Update issue title: + +``` +$ bb issue edit 12 --title "Login button unresponsive on mobile" +``` + +Change issue priority: + +``` +$ bb issue edit 12 --priority critical +``` + +Reassign an issue: + +``` +$ bb issue edit 12 --assignee bobsmith +``` + +Remove assignee from issue: + +``` +$ bb issue edit 12 --unassign +``` + +Update multiple fields: + +``` +$ bb issue edit 12 --title "Updated title" --priority major --kind enhancement +``` + +## See also + +- [bb issue view](#bb-issue-view) - View issue details +- [bb issue close](#bb-issue-close) - Close an issue + +--- + +# bb issue close + +Close an issue. + +## Synopsis + +``` +bb issue close <id> [flags] +``` + +## Description + +Close an issue by setting its state to `resolved`. Optionally add a closing comment explaining the resolution. + +Closed issues can be reopened using `bb issue reopen`. + +## Flags + +| Flag | Description | +|------|-------------| +| `-c, --comment <text>` | Add a comment when closing | +| `-r, --reason <state>` | Close reason/state: `resolved`, `invalid`, `duplicate`, `wontfix` (default: resolved) | +| `-R, --repo <repo>` | Select repository as `workspace/repo` | +| `-h, --help` | Show help for command | + +## Examples + +Close an issue: + +``` +$ bb issue close 12 +Issue #12 closed +``` + +Close with a comment: + +``` +$ bb issue close 12 --comment "Fixed in commit abc123" +``` + +Close as duplicate: + +``` +$ bb issue close 12 --reason duplicate --comment "Duplicate of #8" +``` + +Close as won't fix: + +``` +$ bb issue close 12 --reason wontfix --comment "Out of scope for this release" +``` + +## See also + +- [bb issue reopen](#bb-issue-reopen) - Reopen an issue +- [bb issue view](#bb-issue-view) - View issue details + +--- + +# bb issue reopen + +Reopen a closed issue. + +## Synopsis + +``` +bb issue reopen <id> [flags] +``` + +## Description + +Reopen a previously closed issue by setting its state back to `open`. Optionally add a comment explaining why the issue is being reopened. + +## Flags + +| Flag | Description | +|------|-------------| +| `-c, --comment <text>` | Add a comment when reopening | +| `-R, --repo <repo>` | Select repository as `workspace/repo` | +| `-h, --help` | Show help for command | + +## Examples + +Reopen an issue: + +``` +$ bb issue reopen 12 +Issue #12 reopened +``` + +Reopen with a comment: + +``` +$ bb issue reopen 12 --comment "Bug still occurs in edge case" +``` + +## See also + +- [bb issue close](#bb-issue-close) - Close an issue +- [bb issue view](#bb-issue-view) - View issue details + +--- + +# bb issue comment + +Add a comment to an issue. + +## Synopsis + +``` +bb issue comment <id> [flags] +``` + +## Description + +Add a comment to an existing issue. If the `--body` flag is not provided, an interactive editor will open to compose the comment. + +Comments are displayed when viewing an issue with `bb issue view --comments`. + +## Flags + +| Flag | Description | +|------|-------------| +| `-b, --body <text>` | Comment text | +| `-R, --repo <repo>` | Select repository as `workspace/repo` | +| `--json` | Output created comment in JSON format | +| `-w, --web` | Open the issue in browser after commenting | +| `-h, --help` | Show help for command | + +## Examples + +Add a comment interactively: + +``` +$ bb issue comment 12 +``` + +Add a comment directly: + +``` +$ bb issue comment 12 --body "I can reproduce this on version 2.1.0" +``` + +Add a comment from a file: + +``` +$ bb issue comment 12 --body "$(cat comment.txt)" +``` + +Add a comment and open in browser: + +``` +$ bb issue comment 12 --body "See attached screenshot" --web +``` + +## See also + +- [bb issue view](#bb-issue-view) - View issue details +- [bb issue edit](#bb-issue-edit) - Edit an issue + +--- + +# bb issue delete + +Delete an issue. + +## Synopsis + +``` +bb issue delete <id> [flags] +``` + +## Description + +Permanently delete an issue from the repository. This action cannot be undone. + +By default, you will be prompted to confirm the deletion. Use `--yes` to skip the confirmation prompt. + +## Flags + +| Flag | Description | +|------|-------------| +| `-y, --yes` | Skip confirmation prompt | +| `-R, --repo <repo>` | Select repository as `workspace/repo` | +| `-h, --help` | Show help for command | + +## Examples + +Delete an issue (with confirmation): + +``` +$ bb issue delete 12 +? Are you sure you want to delete issue #12 "Fix login redirect"? Yes +Issue #12 deleted +``` + +Delete without confirmation: + +``` +$ bb issue delete 12 --yes +Issue #12 deleted +``` + +Delete an issue in a specific repository: + +``` +$ bb issue delete 12 -R myworkspace/myrepo --yes +``` + +## See also + +- [bb issue close](#bb-issue-close) - Close an issue +- [bb issue list](#bb-issue-list) - List issues diff --git a/docs/commands/bb_pipeline.md b/docs/commands/bb_pipeline.md new file mode 100644 index 0000000..9c0fc50 --- /dev/null +++ b/docs/commands/bb_pipeline.md @@ -0,0 +1,409 @@ +# bb pipeline + +Manage Bitbucket Pipelines. + +## Synopsis + +``` +bb pipeline <subcommand> [flags] +``` + +## Description + +Work with Bitbucket Pipelines for continuous integration and deployment. List pipeline runs, view details, trigger new builds, monitor logs, and manage pipeline execution. + +## Subcommands + +- [bb pipeline list](#bb-pipeline-list) - List pipeline runs +- [bb pipeline view](#bb-pipeline-view) - View pipeline details +- [bb pipeline run](#bb-pipeline-run) - Trigger a pipeline run +- [bb pipeline logs](#bb-pipeline-logs) - View pipeline logs +- [bb pipeline steps](#bb-pipeline-steps) - List pipeline steps +- [bb pipeline stop](#bb-pipeline-stop) - Stop a running pipeline + +--- + +# bb pipeline list + +List pipeline runs for a repository. + +## Synopsis + +``` +bb pipeline list [flags] +``` + +## Description + +Display a list of pipeline runs for the current or specified repository. By default, shows the most recent pipeline runs with their status, branch, and trigger information. + +Results are sorted by creation time, with the most recent pipelines first. + +## Flags + +| Flag | Description | +|------|-------------| +| `-R, --repo <owner/repo>` | Select a repository (default: current repository) | +| `-b, --branch <name>` | Filter by branch name | +| `-s, --status <status>` | Filter by status (PENDING, IN_PROGRESS, SUCCESSFUL, FAILED, STOPPED) | +| `-L, --limit <number>` | Maximum number of results to return (default: 30) | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +List recent pipelines: + +``` +$ bb pipeline list +ID STATUS BRANCH TRIGGER DURATION CREATED +1234 SUCCESSFUL main push 2m 34s 2026-02-05 09:15:00 +1233 FAILED feature push 1m 12s 2026-02-05 08:45:00 +1232 SUCCESSFUL main pull_request 3m 01s 2026-02-04 16:30:00 +``` + +Filter by branch: + +``` +$ bb pipeline list --branch main +``` + +Filter by status: + +``` +$ bb pipeline list --status FAILED +``` + +List pipelines for a specific repository: + +``` +$ bb pipeline list -R myworkspace/myrepo +``` + +## See also + +- [bb pipeline view](#bb-pipeline-view) - View pipeline details +- [bb pipeline run](#bb-pipeline-run) - Trigger a pipeline run + +--- + +# bb pipeline view + +View details of a specific pipeline run. + +## Synopsis + +``` +bb pipeline view <pipeline-id> [flags] +``` + +## Description + +Display detailed information about a specific pipeline run, including its status, duration, trigger information, and step summary. + +If no pipeline ID is provided, the most recent pipeline run for the current branch is shown. + +## Flags + +| Flag | Description | +|------|-------------| +| `-R, --repo <owner/repo>` | Select a repository (default: current repository) | +| `-w, --web` | Open the pipeline in a browser | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +View a specific pipeline: + +``` +$ bb pipeline view 1234 +Pipeline #1234 +Status: SUCCESSFUL +Branch: main +Commit: abc1234 - Fix authentication bug +Trigger: push by johndoe +Started: 2026-02-05 09:15:00 UTC +Duration: 2m 34s + +Steps: + ✓ Build 45s + ✓ Test 1m 20s + ✓ Deploy 29s +``` + +View the most recent pipeline for current branch: + +``` +$ bb pipeline view +``` + +Open pipeline in browser: + +``` +$ bb pipeline view 1234 --web +Opening https://bitbucket.org/myworkspace/myrepo/pipelines/results/1234 +``` + +## See also + +- [bb pipeline list](#bb-pipeline-list) - List pipeline runs +- [bb pipeline logs](#bb-pipeline-logs) - View pipeline logs +- [bb pipeline steps](#bb-pipeline-steps) - List pipeline steps + +--- + +# bb pipeline run + +Trigger a new pipeline run. + +## Synopsis + +``` +bb pipeline run [flags] +``` + +## Description + +Trigger a new pipeline run for the current or specified branch. By default, runs the default pipeline defined in `bitbucket-pipelines.yml`. + +You can specify a custom pipeline or target using the `--pipeline` and `--target` flags. + +## Flags + +| Flag | Description | +|------|-------------| +| `-R, --repo <owner/repo>` | Select a repository (default: current repository) | +| `-b, --branch <name>` | Branch to run pipeline on (default: current branch) | +| `-p, --pipeline <name>` | Custom pipeline name to run | +| `-t, --target <type>` | Pipeline target type (branch, tag, bookmark, custom) | +| `--commit <sha>` | Specific commit SHA to run pipeline on | +| `-v, --variable <key=value>` | Pipeline variable (can be specified multiple times) | +| `--wait` | Wait for pipeline to complete | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +Trigger a pipeline on the current branch: + +``` +$ bb pipeline run +Triggered pipeline #1235 on branch main +https://bitbucket.org/myworkspace/myrepo/pipelines/results/1235 +``` + +Run pipeline on a specific branch: + +``` +$ bb pipeline run --branch feature-branch +``` + +Run a custom pipeline: + +``` +$ bb pipeline run --pipeline deploy-staging +``` + +Run with custom variables: + +``` +$ bb pipeline run -v ENV=staging -v DEBUG=true +``` + +Trigger and wait for completion: + +``` +$ bb pipeline run --wait +Triggered pipeline #1235 on branch main +Waiting for pipeline to complete... +Pipeline #1235 completed with status: SUCCESSFUL +``` + +## See also + +- [bb pipeline list](#bb-pipeline-list) - List pipeline runs +- [bb pipeline view](#bb-pipeline-view) - View pipeline details +- [bb pipeline stop](#bb-pipeline-stop) - Stop a running pipeline + +--- + +# bb pipeline logs + +View logs from a pipeline run. + +## Synopsis + +``` +bb pipeline logs <pipeline-id> [flags] +``` + +## Description + +Display logs from a specific pipeline run. By default, shows logs from all steps combined. Use the `--step` flag to view logs from a specific step. + +## Flags + +| Flag | Description | +|------|-------------| +| `-R, --repo <owner/repo>` | Select a repository (default: current repository) | +| `-s, --step <name>` | Show logs for a specific step only | +| `-f, --follow` | Follow log output (for in-progress pipelines) | +| `--failed` | Show only failed step logs | +| `-h, --help` | Show help for command | + +## Examples + +View all logs from a pipeline: + +``` +$ bb pipeline logs 1234 +==> Step: Build ++ npm install +added 523 packages in 12.5s ++ npm run build +Build completed successfully. + +==> Step: Test ++ npm test +All 42 tests passed. +``` + +View logs for a specific step: + +``` +$ bb pipeline logs 1234 --step Test ++ npm test +Running test suite... +All 42 tests passed. +``` + +Follow logs for a running pipeline: + +``` +$ bb pipeline logs 1235 --follow +==> Step: Build (in progress) ++ npm install +Installing dependencies... +``` + +View only failed step logs: + +``` +$ bb pipeline logs 1233 --failed +==> Step: Test (FAILED) ++ npm test +FAIL src/auth.test.js + ✕ should validate token (15ms) + Expected: true + Received: false +``` + +## See also + +- [bb pipeline view](#bb-pipeline-view) - View pipeline details +- [bb pipeline steps](#bb-pipeline-steps) - List pipeline steps + +--- + +# bb pipeline steps + +List steps in a pipeline run. + +## Synopsis + +``` +bb pipeline steps <pipeline-id> [flags] +``` + +## Description + +Display a list of all steps in a specific pipeline run with their status, duration, and execution order. + +## Flags + +| Flag | Description | +|------|-------------| +| `-R, --repo <owner/repo>` | Select a repository (default: current repository) | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +List steps in a pipeline: + +``` +$ bb pipeline steps 1234 +# NAME STATUS DURATION IMAGE +1 Build SUCCESSFUL 45s atlassian/default-image:4 +2 Test SUCCESSFUL 1m 20s atlassian/default-image:4 +3 Deploy SUCCESSFUL 29s atlassian/default-image:4 +``` + +List steps with JSON output: + +``` +$ bb pipeline steps 1234 --json +[ + { + "name": "Build", + "status": "SUCCESSFUL", + "duration_in_seconds": 45, + "image": "atlassian/default-image:4" + }, + ... +] +``` + +## See also + +- [bb pipeline view](#bb-pipeline-view) - View pipeline details +- [bb pipeline logs](#bb-pipeline-logs) - View pipeline logs + +--- + +# bb pipeline stop + +Stop a running pipeline. + +## Synopsis + +``` +bb pipeline stop <pipeline-id> [flags] +``` + +## Description + +Stop a pipeline that is currently in progress. This will terminate all running steps and mark the pipeline as STOPPED. + +This action cannot be undone. You can trigger a new pipeline run using `bb pipeline run`. + +## Flags + +| Flag | Description | +|------|-------------| +| `-R, --repo <owner/repo>` | Select a repository (default: current repository) | +| `-y, --yes` | Skip confirmation prompt | +| `-h, --help` | Show help for command | + +## Examples + +Stop a running pipeline: + +``` +$ bb pipeline stop 1235 +? Are you sure you want to stop pipeline #1235? Yes +Pipeline #1235 has been stopped +``` + +Stop without confirmation: + +``` +$ bb pipeline stop 1235 --yes +Pipeline #1235 has been stopped +``` + +## See also + +- [bb pipeline list](#bb-pipeline-list) - List pipeline runs +- [bb pipeline run](#bb-pipeline-run) - Trigger a pipeline run diff --git a/docs/commands/bb_pr.md b/docs/commands/bb_pr.md new file mode 100644 index 0000000..a268c39 --- /dev/null +++ b/docs/commands/bb_pr.md @@ -0,0 +1,619 @@ +# bb pr + +Work with Bitbucket pull requests. + +## Synopsis + +``` +bb pr <subcommand> [flags] +``` + +## Subcommands + +| Command | Description | +|---------|-------------| +| [list](#bb-pr-list) | List pull requests | +| [view](#bb-pr-view) | View pull request details | +| [create](#bb-pr-create) | Create a pull request | +| [merge](#bb-pr-merge) | Merge a pull request | +| [checkout](#bb-pr-checkout) | Checkout a pull request locally | +| [close](#bb-pr-close) | Decline/close a pull request | +| [reopen](#bb-pr-reopen) | Reopen a declined pull request | +| [edit](#bb-pr-edit) | Edit a pull request | +| [review](#bb-pr-review) | Review a pull request | +| [comment](#bb-pr-comment) | Add a comment to a pull request | +| [diff](#bb-pr-diff) | View pull request diff | +| [checks](#bb-pr-checks) | View CI/CD status for a pull request | + +--- + +## bb pr list + +List pull requests in the current repository. + +### Synopsis + +``` +bb pr list [flags] +``` + +### Description + +Lists pull requests from the current Bitbucket repository. By default, shows open pull requests. Use flags to filter by state, author, or reviewer. + +### Flags + +| Flag | Description | +|------|-------------| +| `--state <state>` | Filter by state: `open`, `merged`, `declined`, `all` (default: `open`) | +| `--author <username>` | Filter by author username | +| `--reviewer <username>` | Filter by reviewer username | +| `--limit <n>` | Maximum number of results to return | +| `--json` | Output in JSON format | + +### Examples + +```bash +# List open pull requests +bb pr list + +# List all merged pull requests +bb pr list --state merged + +# List PRs authored by a specific user +bb pr list --author johndoe + +# List PRs where you are a reviewer +bb pr list --reviewer janedoe + +# Combine filters +bb pr list --state open --author johndoe --limit 10 +``` + +### See also + +- [bb pr view](#bb-pr-view) +- [bb pr create](#bb-pr-create) + +--- + +## bb pr view + +View pull request details. + +### Synopsis + +``` +bb pr view <number> [flags] +``` + +### Description + +Displays detailed information about a pull request, including title, description, author, reviewers, approval status, and build status. + +### Arguments + +| Argument | Description | +|----------|-------------| +| `<number>` | Pull request ID (required) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--web` | Open the pull request in a web browser | +| `--json` | Output in JSON format | + +### Examples + +```bash +# View pull request #42 +bb pr view 42 + +# Open PR in browser +bb pr view 42 --web + +# Get PR details as JSON +bb pr view 42 --json +``` + +### See also + +- [bb pr list](#bb-pr-list) +- [bb pr diff](#bb-pr-diff) + +--- + +## bb pr create + +Create a new pull request. + +### Synopsis + +``` +bb pr create [flags] +``` + +### Description + +Creates a new pull request from the current branch (or specified head branch) to the target base branch. If `--title` is not provided, opens an editor to compose the PR title and description. + +### Flags + +| Flag | Description | +|------|-------------| +| `--title <string>` | Pull request title | +| `--body <string>` | Pull request description | +| `--base <branch>` | Base branch to merge into (default: repository default branch) | +| `--head <branch>` | Head branch containing changes (default: current branch) | +| `--draft` | Create as a draft pull request | +| `--reviewer <username>` | Add reviewer (can be repeated) | +| `--close-source-branch` | Delete source branch after merge | +| `--web` | Open the created PR in a web browser | + +### Examples + +```bash +# Create PR with title and body +bb pr create --title "Add new feature" --body "This PR adds..." + +# Create PR from feature branch to main +bb pr create --head feature/login --base main --title "Login feature" + +# Create draft PR +bb pr create --title "WIP: New feature" --draft + +# Create PR with reviewers +bb pr create --title "Bug fix" --reviewer alice --reviewer bob + +# Create PR and open in browser +bb pr create --title "Quick fix" --web +``` + +### See also + +- [bb pr edit](#bb-pr-edit) +- [bb pr merge](#bb-pr-merge) + +--- + +## bb pr merge + +Merge a pull request. + +### Synopsis + +``` +bb pr merge <number> [flags] +``` + +### Description + +Merges an approved pull request into its target branch. Requires the PR to be approved and all required checks to pass (if configured). + +### Arguments + +| Argument | Description | +|----------|-------------| +| `<number>` | Pull request ID (required) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--merge-strategy <strategy>` | Merge strategy: `merge-commit`, `squash`, `fast-forward` (default: `merge-commit`) | +| `--delete-branch` | Delete the source branch after merging | +| `--message <string>` | Custom merge commit message | + +### Examples + +```bash +# Merge PR with default settings +bb pr merge 42 + +# Squash merge and delete branch +bb pr merge 42 --merge-strategy squash --delete-branch + +# Fast-forward merge +bb pr merge 42 --merge-strategy fast-forward + +# Merge with custom commit message +bb pr merge 42 --message "Merge feature X into main" +``` + +### See also + +- [bb pr view](#bb-pr-view) +- [bb pr checks](#bb-pr-checks) + +--- + +## bb pr checkout + +Checkout a pull request locally. + +### Synopsis + +``` +bb pr checkout <number> [flags] +``` + +### Description + +Fetches and checks out a pull request branch locally for testing or review. Creates a local branch tracking the PR's source branch. + +### Arguments + +| Argument | Description | +|----------|-------------| +| `<number>` | Pull request ID (required) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--branch <name>` | Local branch name to create (default: `pr-<number>`) | +| `--force` | Overwrite existing local branch | + +### Examples + +```bash +# Checkout PR #42 +bb pr checkout 42 + +# Checkout with custom branch name +bb pr checkout 42 --branch review-feature-x + +# Force overwrite existing branch +bb pr checkout 42 --force +``` + +### See also + +- [bb pr view](#bb-pr-view) +- [bb pr diff](#bb-pr-diff) + +--- + +## bb pr close + +Decline/close a pull request. + +### Synopsis + +``` +bb pr close <number> [flags] +``` + +### Description + +Declines (closes) a pull request without merging. The PR can be reopened later using `bb pr reopen`. + +### Arguments + +| Argument | Description | +|----------|-------------| +| `<number>` | Pull request ID (required) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--comment <string>` | Add a comment explaining why the PR is being declined | + +### Examples + +```bash +# Close PR #42 +bb pr close 42 + +# Close with explanation +bb pr close 42 --comment "Superseded by PR #45" +``` + +### See also + +- [bb pr reopen](#bb-pr-reopen) +- [bb pr view](#bb-pr-view) + +--- + +## bb pr reopen + +Reopen a declined pull request. + +### Synopsis + +``` +bb pr reopen <number> [flags] +``` + +### Description + +Reopens a previously declined pull request, returning it to an open state. + +### Arguments + +| Argument | Description | +|----------|-------------| +| `<number>` | Pull request ID (required) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--comment <string>` | Add a comment when reopening | + +### Examples + +```bash +# Reopen PR #42 +bb pr reopen 42 + +# Reopen with comment +bb pr reopen 42 --comment "Ready for re-review after addressing feedback" +``` + +### See also + +- [bb pr close](#bb-pr-close) +- [bb pr view](#bb-pr-view) + +--- + +## bb pr edit + +Edit a pull request. + +### Synopsis + +``` +bb pr edit <number> [flags] +``` + +### Description + +Modifies an existing pull request's title, description, or target branch. At least one flag must be provided. + +### Arguments + +| Argument | Description | +|----------|-------------| +| `<number>` | Pull request ID (required) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--title <string>` | New pull request title | +| `--body <string>` | New pull request description | +| `--base <branch>` | Change the target base branch | +| `--add-reviewer <username>` | Add a reviewer (can be repeated) | +| `--remove-reviewer <username>` | Remove a reviewer (can be repeated) | + +### Examples + +```bash +# Update PR title +bb pr edit 42 --title "Updated: Add new feature" + +# Update description +bb pr edit 42 --body "New description with more details" + +# Change target branch +bb pr edit 42 --base develop + +# Add reviewers +bb pr edit 42 --add-reviewer alice --add-reviewer bob + +# Combined edits +bb pr edit 42 --title "New title" --body "New body" --add-reviewer charlie +``` + +### See also + +- [bb pr create](#bb-pr-create) +- [bb pr view](#bb-pr-view) + +--- + +## bb pr review + +Review a pull request. + +### Synopsis + +``` +bb pr review <number> [flags] +``` + +### Description + +Submit a review for a pull request. You can approve the PR or request changes. + +### Arguments + +| Argument | Description | +|----------|-------------| +| `<number>` | Pull request ID (required) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--approve` | Approve the pull request | +| `--request-changes` | Request changes to the pull request | +| `--unapprove` | Remove your approval | +| `--comment <string>` | Add a review comment | + +### Examples + +```bash +# Approve PR +bb pr review 42 --approve + +# Request changes with comment +bb pr review 42 --request-changes --comment "Please fix the failing tests" + +# Approve with comment +bb pr review 42 --approve --comment "LGTM!" + +# Remove approval +bb pr review 42 --unapprove +``` + +### See also + +- [bb pr view](#bb-pr-view) +- [bb pr comment](#bb-pr-comment) + +--- + +## bb pr comment + +Add a comment to a pull request. + +### Synopsis + +``` +bb pr comment <number> [flags] +``` + +### Description + +Adds a general comment to a pull request. For inline code comments, use the web interface. + +### Arguments + +| Argument | Description | +|----------|-------------| +| `<number>` | Pull request ID (required) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--body <string>` | Comment text (required, or opens editor if not provided) | + +### Examples + +```bash +# Add a comment +bb pr comment 42 --body "Great work on this feature!" + +# Opens editor if --body not provided +bb pr comment 42 +``` + +### See also + +- [bb pr view](#bb-pr-view) +- [bb pr review](#bb-pr-review) + +--- + +## bb pr diff + +View pull request diff. + +### Synopsis + +``` +bb pr diff <number> [flags] +``` + +### Description + +Displays the diff of changes in a pull request. Shows all file changes between the source and destination branches. + +### Arguments + +| Argument | Description | +|----------|-------------| +| `<number>` | Pull request ID (required) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--stat` | Show diffstat instead of full diff | +| `--name-only` | Show only names of changed files | +| `--color` | Force colored output | +| `--no-color` | Disable colored output | + +### Examples + +```bash +# View full diff +bb pr diff 42 + +# View diff statistics +bb pr diff 42 --stat + +# List changed files only +bb pr diff 42 --name-only +``` + +### See also + +- [bb pr view](#bb-pr-view) +- [bb pr checkout](#bb-pr-checkout) + +--- + +## bb pr checks + +View CI/CD status for a pull request. + +### Synopsis + +``` +bb pr checks <number> [flags] +``` + +### Description + +Displays the status of all CI/CD pipelines and checks associated with a pull request. Shows build status, test results, and other configured checks. + +### Arguments + +| Argument | Description | +|----------|-------------| +| `<number>` | Pull request ID (required) | + +### Flags + +| Flag | Description | +|------|-------------| +| `--watch` | Watch for status changes (updates every 10 seconds) | +| `--fail-fast` | Exit with error code if any check fails | +| `--json` | Output in JSON format | + +### Examples + +```bash +# View check status +bb pr checks 42 + +# Watch checks until completion +bb pr checks 42 --watch + +# Check status for CI scripts +bb pr checks 42 --fail-fast + +# Get checks as JSON +bb pr checks 42 --json +``` + +### See also + +- [bb pr view](#bb-pr-view) +- [bb pr merge](#bb-pr-merge) + +--- + +## See also + +- [bb repo](bb_repo.md) - Work with repositories +- [bb issue](bb_issue.md) - Work with issues +- [bb pipeline](bb_pipeline.md) - Work with pipelines diff --git a/docs/commands/bb_project.md b/docs/commands/bb_project.md new file mode 100644 index 0000000..0818600 --- /dev/null +++ b/docs/commands/bb_project.md @@ -0,0 +1,217 @@ +# bb project + +Manage Bitbucket projects. + +## Synopsis + +``` +bb project <subcommand> [flags] +``` + +## Description + +Work with Bitbucket projects within a workspace. Projects are used to organize and group related repositories. + +Projects provide a way to manage permissions and settings across multiple repositories at once. + +## Subcommands + +- [bb project list](#bb-project-list) - List projects +- [bb project view](#bb-project-view) - View project details +- [bb project create](#bb-project-create) - Create a new project + +--- + +# bb project list + +List projects in a workspace. + +## Synopsis + +``` +bb project list [flags] +``` + +## Description + +Display a list of projects in a workspace. Projects help organize related repositories and can have their own permissions and settings. + +## Flags + +| Flag | Description | +|------|-------------| +| `-w, --workspace <slug>` | Workspace to list projects from (default: configured workspace) | +| `-L, --limit <number>` | Maximum number of projects to list (default: 30) | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +List projects in the default workspace: + +``` +$ bb project list +KEY NAME REPOSITORIES DESCRIPTION +CORE Core Platform 12 Core platform services +WEB Web Applications 8 Frontend applications +MOBILE Mobile Apps 5 iOS and Android apps +INFRA Infrastructure 15 DevOps and infrastructure +``` + +List projects in a specific workspace: + +``` +$ bb project list --workspace acme-corp +KEY NAME REPOSITORIES DESCRIPTION +PROD Product 20 Main product repos +INTERNAL Internal Tools 7 Internal tooling +``` + +Output as JSON: + +``` +$ bb project list --json +[ + { + "key": "CORE", + "name": "Core Platform", + "repository_count": 12, + "description": "Core platform services" + }, + ... +] +``` + +## See also + +- [bb project view](#bb-project-view) - View project details +- [bb project create](#bb-project-create) - Create a new project + +--- + +# bb project view + +View details of a project. + +## Synopsis + +``` +bb project view <project-key> [flags] +``` + +## Description + +Display detailed information about a specific project, including its name, description, and associated repositories. + +## Flags + +| Flag | Description | +|------|-------------| +| `-w, --workspace <slug>` | Workspace containing the project (default: configured workspace) | +| `--web` | Open the project in a browser | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +View project details: + +``` +$ bb project view CORE +Key: CORE +Name: Core Platform +Workspace: myteam +Description: Core platform services and shared libraries +Created: 2024-01-15 +Updated: 2026-02-01 +Repositories: 12 +Private: Yes + +Links: + URL: https://bitbucket.org/myteam/workspace/projects/CORE + Avatar: https://bitbucket.org/account/myteam/projects/CORE/avatar +``` + +View project in a specific workspace: + +``` +$ bb project view PROD --workspace acme-corp +``` + +Open project in browser: + +``` +$ bb project view CORE --web +Opening https://bitbucket.org/myteam/workspace/projects/CORE +``` + +## See also + +- [bb project list](#bb-project-list) - List projects +- [bb project create](#bb-project-create) - Create a new project + +--- + +# bb project create + +Create a new project. + +## Synopsis + +``` +bb project create <project-key> [flags] +``` + +## Description + +Create a new project in a workspace. Projects are used to group and organize related repositories. + +The project key must be unique within the workspace and typically uses uppercase letters (e.g., CORE, WEB, API). + +## Flags + +| Flag | Description | +|------|-------------| +| `-w, --workspace <slug>` | Workspace to create the project in (default: configured workspace) | +| `-n, --name <name>` | Display name for the project (required) | +| `-d, --description <text>` | Project description | +| `--private` | Make the project private (default: follows workspace settings) | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +Create a project: + +``` +$ bb project create API --name "API Services" +Created project 'API' in workspace myteam +https://bitbucket.org/myteam/workspace/projects/API +``` + +Create a project with description: + +``` +$ bb project create DOCS --name "Documentation" --description "All documentation repositories" +Created project 'DOCS' in workspace myteam +https://bitbucket.org/myteam/workspace/projects/DOCS +``` + +Create a private project: + +``` +$ bb project create INTERNAL --name "Internal Tools" --private +Created project 'INTERNAL' in workspace myteam (private) +``` + +Create in a specific workspace: + +``` +$ bb project create QA --name "Quality Assurance" --workspace acme-corp +Created project 'QA' in workspace acme-corp +``` + +## See also + +- [bb project list](#bb-project-list) - List projects +- [bb project view](#bb-project-view) - View project details diff --git a/docs/commands/bb_repo.md b/docs/commands/bb_repo.md new file mode 100644 index 0000000..213a1f9 --- /dev/null +++ b/docs/commands/bb_repo.md @@ -0,0 +1,302 @@ +# bb repo + +Manage Bitbucket repositories. + +## Synopsis + +``` +bb repo <subcommand> [flags] +``` + +## Subcommands + +- [list](#bb-repo-list) - List repositories +- [view](#bb-repo-view) - View repository details +- [clone](#bb-repo-clone) - Clone a repository +- [create](#bb-repo-create) - Create a new repository +- [fork](#bb-repo-fork) - Fork a repository +- [delete](#bb-repo-delete) - Delete a repository +- [sync](#bb-repo-sync) - Sync fork with upstream +- [set-default](#bb-repo-set-default) - Set default repository for directory + +--- + +## bb repo list + +List repositories in a workspace. + +### Synopsis + +``` +bb repo list [flags] +``` + +### Description + +Lists repositories accessible to the authenticated user. By default, lists repositories in the current workspace (determined from git remote or configuration). + +### Flags + +| Flag | Description | +|------|-------------| +| `--workspace`, `-w` | Workspace slug to list repositories from | +| `--limit`, `-l` | Maximum number of repositories to list (default: 30) | + +### Examples + +```bash +# List repositories in current workspace +bb repo list + +# List repositories in a specific workspace +bb repo list --workspace myteam + +# List first 50 repositories +bb repo list --limit 50 +``` + +--- + +## bb repo view + +View repository details. + +### Synopsis + +``` +bb repo view [<repo>] [flags] +``` + +### Description + +Displays detailed information about a repository including description, visibility, default branch, clone URLs, and recent activity. If no repository is specified, uses the repository in the current directory. + +### Flags + +| Flag | Description | +|------|-------------| +| `--web`, `-w` | Open the repository in the browser | + +### Examples + +```bash +# View current repository details +bb repo view + +# View a specific repository +bb repo view myworkspace/myrepo + +# Open repository in browser +bb repo view --web + +# Open specific repository in browser +bb repo view myworkspace/myrepo --web +``` + +--- + +## bb repo clone + +Clone a repository locally. + +### Synopsis + +``` +bb repo clone <workspace/repo> [directory] [flags] +``` + +### Description + +Clones a Bitbucket repository to the local filesystem. The repository must be specified in `workspace/repo` format. Optionally specify a target directory name. + +### Flags + +| Flag | Description | +|------|-------------| +| `--depth`, `-d` | Create a shallow clone with specified commit depth | +| `--branch`, `-b` | Clone a specific branch | + +### Examples + +```bash +# Clone a repository +bb repo clone myworkspace/myrepo + +# Clone into a specific directory +bb repo clone myworkspace/myrepo my-local-dir + +# Shallow clone with depth of 1 +bb repo clone myworkspace/myrepo --depth 1 + +# Clone a specific branch +bb repo clone myworkspace/myrepo --branch develop + +# Combine flags +bb repo clone myworkspace/myrepo --branch feature --depth 10 +``` + +--- + +## bb repo create + +Create a new repository. + +### Synopsis + +``` +bb repo create [flags] +``` + +### Description + +Creates a new repository in the specified workspace. If run interactively, prompts for required information. The repository name is derived from the `--name` flag or prompted interactively. + +### Flags + +| Flag | Description | +|------|-------------| +| `--name`, `-n` | Name of the repository | +| `--private`, `-p` | Make the repository private (default: true) | +| `--description`, `-d` | Description of the repository | +| `--project` | Project key to assign the repository to | + +### Examples + +```bash +# Create a repository interactively +bb repo create + +# Create a private repository with name +bb repo create --name my-new-repo + +# Create a public repository with description +bb repo create --name my-new-repo --private=false --description "My awesome project" + +# Create repository in a specific project +bb repo create --name my-new-repo --project PROJ +``` + +--- + +## bb repo fork + +Fork a repository. + +### Synopsis + +``` +bb repo fork <workspace/repo> [flags] +``` + +### Description + +Creates a fork of the specified repository in your personal workspace or a workspace you have access to. The fork maintains a link to the upstream repository. + +### Examples + +```bash +# Fork a repository to your personal workspace +bb repo fork upstream-workspace/repo-name + +# Fork and clone in one flow (coming soon) +bb repo fork upstream-workspace/repo-name --clone +``` + +--- + +## bb repo delete + +Delete a repository. + +### Synopsis + +``` +bb repo delete <workspace/repo> [flags] +``` + +### Description + +Permanently deletes a repository. This action cannot be undone. By default, prompts for confirmation before deletion. + +### Flags + +| Flag | Description | +|------|-------------| +| `--yes`, `-y` | Skip confirmation prompt | + +### Examples + +```bash +# Delete a repository (with confirmation) +bb repo delete myworkspace/myrepo + +# Delete without confirmation +bb repo delete myworkspace/myrepo --yes +``` + +--- + +## bb repo sync + +Sync fork with upstream repository. + +### Synopsis + +``` +bb repo sync [flags] +``` + +### Description + +Synchronizes a forked repository with its upstream parent. This fetches changes from the upstream repository and merges them into the fork's default branch. Must be run from within a forked repository. + +### Examples + +```bash +# Sync current fork with upstream +bb repo sync +``` + +--- + +## bb repo set-default + +Set the default repository for the current directory. + +### Synopsis + +``` +bb repo set-default [<workspace/repo>] [flags] +``` + +### Description + +Sets or manages the default repository associated with the current directory. This is useful when working in directories that aren't git repositories or when you want to override the detected repository. + +### Flags + +| Flag | Description | +|------|-------------| +| `--view`, `-v` | View the currently set default repository | +| `--unset`, `-u` | Remove the default repository setting | + +### Examples + +```bash +# Set default repository for current directory +bb repo set-default myworkspace/myrepo + +# View current default repository +bb repo set-default --view + +# Remove default repository setting +bb repo set-default --unset +``` + +--- + +## See Also + +- [bb pr](bb_pr.md) - Manage pull requests +- [bb issue](bb_issue.md) - Manage issues +- [bb workspace](bb_workspace.md) - Manage workspaces diff --git a/docs/commands/bb_snippet.md b/docs/commands/bb_snippet.md new file mode 100644 index 0000000..9505625 --- /dev/null +++ b/docs/commands/bb_snippet.md @@ -0,0 +1,374 @@ +# bb snippet + +Manage Bitbucket snippets. + +## Synopsis + +``` +bb snippet <subcommand> [flags] +``` + +## Description + +Create, view, and manage code snippets in Bitbucket. Snippets are small pieces of code or text that can be shared with others, similar to GitHub Gists. + +Snippets can be public or private, and support multiple files with syntax highlighting. + +## Subcommands + +- [bb snippet list](#bb-snippet-list) - List snippets +- [bb snippet view](#bb-snippet-view) - View a snippet +- [bb snippet create](#bb-snippet-create) - Create a new snippet +- [bb snippet edit](#bb-snippet-edit) - Edit an existing snippet +- [bb snippet delete](#bb-snippet-delete) - Delete a snippet + +--- + +# bb snippet list + +List snippets. + +## Synopsis + +``` +bb snippet list [flags] +``` + +## Description + +Display a list of snippets owned by the authenticated user or a specified workspace. Shows the snippet title, visibility, and last modification date. + +## Flags + +| Flag | Description | +|------|-------------| +| `-w, --workspace <slug>` | List snippets from a specific workspace | +| `-r, --role <role>` | Filter by role (owner, contributor, member) | +| `-L, --limit <number>` | Maximum number of snippets to list (default: 30) | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +List your snippets: + +``` +$ bb snippet list +ID TITLE VISIBILITY UPDATED +abc123 Docker Compose Template public 2026-02-05 +def456 Bash Utilities private 2026-02-03 +ghi789 Python Helpers public 2026-01-28 +``` + +List snippets from a workspace: + +``` +$ bb snippet list --workspace myteam +ID TITLE VISIBILITY UPDATED +jkl012 Team Code Standards public 2026-02-04 +mno345 Deployment Scripts private 2026-02-01 +``` + +Output as JSON: + +``` +$ bb snippet list --json +[ + { + "id": "abc123", + "title": "Docker Compose Template", + "is_private": false, + "updated_on": "2026-02-05T10:30:00Z" + }, + ... +] +``` + +## See also + +- [bb snippet view](#bb-snippet-view) - View a snippet +- [bb snippet create](#bb-snippet-create) - Create a new snippet + +--- + +# bb snippet view + +View a snippet. + +## Synopsis + +``` +bb snippet view <snippet-id> [flags] +``` + +## Description + +Display the contents of a specific snippet. By default, shows all files in the snippet with syntax highlighting. + +## Flags + +| Flag | Description | +|------|-------------| +| `-f, --file <filename>` | Show only a specific file from the snippet | +| `-r, --raw` | Output raw content without formatting | +| `-w, --web` | Open the snippet in a browser | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +View a snippet: + +``` +$ bb snippet view abc123 +Snippet: Docker Compose Template +Owner: johndoe +Created: 2026-01-15 +Updated: 2026-02-05 +URL: https://bitbucket.org/snippets/johndoe/abc123 + +--- docker-compose.yml --- +version: '3.8' +services: + web: + build: . + ports: + - "8080:8080" + db: + image: postgres:14 + environment: + POSTGRES_PASSWORD: secret +``` + +View a specific file: + +``` +$ bb snippet view abc123 --file docker-compose.yml +version: '3.8' +services: + web: + build: . +... +``` + +Get raw content (useful for piping): + +``` +$ bb snippet view abc123 --raw > docker-compose.yml +``` + +Open in browser: + +``` +$ bb snippet view abc123 --web +Opening https://bitbucket.org/snippets/johndoe/abc123 +``` + +## See also + +- [bb snippet list](#bb-snippet-list) - List snippets +- [bb snippet edit](#bb-snippet-edit) - Edit an existing snippet + +--- + +# bb snippet create + +Create a new snippet. + +## Synopsis + +``` +bb snippet create [flags] +``` + +## Description + +Create a new snippet from files or standard input. Snippets can contain one or multiple files and can be public or private. + +## Flags + +| Flag | Description | +|------|-------------| +| `-t, --title <title>` | Title for the snippet | +| `-f, --filename <name>` | Filename when reading from stdin | +| `-p, --private` | Make the snippet private (default: public) | +| `-w, --workspace <slug>` | Create snippet in a workspace | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +Create a snippet from a file: + +``` +$ bb snippet create script.sh +Created snippet 'script.sh' +https://bitbucket.org/snippets/johndoe/xyz789 +``` + +Create a snippet with a title: + +``` +$ bb snippet create config.yml --title "My Config Template" +Created snippet 'My Config Template' +https://bitbucket.org/snippets/johndoe/xyz789 +``` + +Create from multiple files: + +``` +$ bb snippet create docker-compose.yml Dockerfile .env.example +Created snippet with 3 files +https://bitbucket.org/snippets/johndoe/xyz789 +``` + +Create a private snippet: + +``` +$ bb snippet create secrets.sh --private +Created private snippet 'secrets.sh' +https://bitbucket.org/snippets/johndoe/xyz789 +``` + +Create from stdin: + +``` +$ echo 'echo "Hello World"' | bb snippet create --filename hello.sh +Created snippet 'hello.sh' +https://bitbucket.org/snippets/johndoe/xyz789 +``` + +Create in a workspace: + +``` +$ bb snippet create team-script.sh --workspace myteam +Created snippet 'team-script.sh' in workspace myteam +https://bitbucket.org/snippets/myteam/xyz789 +``` + +## See also + +- [bb snippet list](#bb-snippet-list) - List snippets +- [bb snippet view](#bb-snippet-view) - View a snippet +- [bb snippet edit](#bb-snippet-edit) - Edit an existing snippet + +--- + +# bb snippet edit + +Edit an existing snippet. + +## Synopsis + +``` +bb snippet edit <snippet-id> [flags] +``` + +## Description + +Edit an existing snippet by updating its title, adding files, removing files, or replacing file contents. + +You can only edit snippets that you own or have write access to. + +## Flags + +| Flag | Description | +|------|-------------| +| `-t, --title <title>` | Update the snippet title | +| `-a, --add <file>` | Add a file to the snippet | +| `-r, --remove <filename>` | Remove a file from the snippet | +| `-u, --update <file>` | Update an existing file in the snippet | +| `-e, --editor` | Open snippet in your default editor | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +Update snippet title: + +``` +$ bb snippet edit abc123 --title "Updated Docker Template" +Updated snippet 'Updated Docker Template' +``` + +Add a file to the snippet: + +``` +$ bb snippet edit abc123 --add nginx.conf +Added nginx.conf to snippet abc123 +``` + +Remove a file: + +``` +$ bb snippet edit abc123 --remove old-config.yml +Removed old-config.yml from snippet abc123 +``` + +Update an existing file: + +``` +$ bb snippet edit abc123 --update docker-compose.yml +Updated docker-compose.yml in snippet abc123 +``` + +Open in editor: + +``` +$ bb snippet edit abc123 --editor +Opening snippet in editor... +Updated snippet abc123 +``` + +## See also + +- [bb snippet view](#bb-snippet-view) - View a snippet +- [bb snippet create](#bb-snippet-create) - Create a new snippet +- [bb snippet delete](#bb-snippet-delete) - Delete a snippet + +--- + +# bb snippet delete + +Delete a snippet. + +## Synopsis + +``` +bb snippet delete <snippet-id> [flags] +``` + +## Description + +Permanently delete a snippet. This action cannot be undone. + +You can only delete snippets that you own or have admin access to. + +## Flags + +| Flag | Description | +|------|-------------| +| `-y, --yes` | Skip confirmation prompt | +| `-h, --help` | Show help for command | + +## Examples + +Delete a snippet: + +``` +$ bb snippet delete abc123 +? Are you sure you want to delete snippet 'Docker Compose Template'? Yes +Deleted snippet abc123 +``` + +Delete without confirmation: + +``` +$ bb snippet delete abc123 --yes +Deleted snippet abc123 +``` + +## See also + +- [bb snippet list](#bb-snippet-list) - List snippets +- [bb snippet create](#bb-snippet-create) - Create a new snippet diff --git a/docs/commands/bb_workspace.md b/docs/commands/bb_workspace.md new file mode 100644 index 0000000..3f582c6 --- /dev/null +++ b/docs/commands/bb_workspace.md @@ -0,0 +1,230 @@ +# bb workspace + +Manage Bitbucket workspaces. + +## Synopsis + +``` +bb workspace <subcommand> [flags] +``` + +## Description + +Work with Bitbucket workspaces. List available workspaces, view workspace details, and manage workspace members. + +Workspaces are the top-level organizational unit in Bitbucket Cloud. Each workspace can contain multiple repositories and projects. + +## Subcommands + +- [bb workspace list](#bb-workspace-list) - List workspaces +- [bb workspace view](#bb-workspace-view) - View workspace details +- [bb workspace members](#bb-workspace-members) - List workspace members + +--- + +# bb workspace list + +List workspaces you have access to. + +## Synopsis + +``` +bb workspace list [flags] +``` + +## Description + +Display a list of all Bitbucket workspaces that the authenticated user has access to. This includes workspaces you own and workspaces you've been invited to. + +## Flags + +| Flag | Description | +|------|-------------| +| `-r, --role <role>` | Filter by your role (owner, collaborator, member) | +| `-L, --limit <number>` | Maximum number of workspaces to list (default: 30) | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +List all workspaces: + +``` +$ bb workspace list +SLUG NAME ROLE REPOSITORIES +myteam My Team owner 25 +acme-corp ACME Corporation collaborator 142 +open-source Open Source Projects member 8 +``` + +Filter by role: + +``` +$ bb workspace list --role owner +SLUG NAME ROLE REPOSITORIES +myteam My Team owner 25 +personal Personal owner 12 +``` + +List with JSON output: + +``` +$ bb workspace list --json +[ + { + "slug": "myteam", + "name": "My Team", + "role": "owner", + "repository_count": 25 + }, + ... +] +``` + +## See also + +- [bb workspace view](#bb-workspace-view) - View workspace details +- [bb workspace members](#bb-workspace-members) - List workspace members + +--- + +# bb workspace view + +View details of a workspace. + +## Synopsis + +``` +bb workspace view [workspace] [flags] +``` + +## Description + +Display detailed information about a specific workspace, including its name, description, creation date, and summary statistics. + +If no workspace is specified, the default workspace (if configured) is shown. + +## Flags + +| Flag | Description | +|------|-------------| +| `-w, --web` | Open the workspace in a browser | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +View workspace details: + +``` +$ bb workspace view myteam +Name: My Team +Slug: myteam +Type: team +Created: 2024-03-15 +Repositories: 25 +Projects: 5 +Members: 12 + +Links: + Website: https://bitbucket.org/myteam + Avatar: https://bitbucket.org/account/myteam/avatar +``` + +View the default workspace: + +``` +$ bb workspace view +Name: My Team +Slug: myteam +... +``` + +Open workspace in browser: + +``` +$ bb workspace view myteam --web +Opening https://bitbucket.org/myteam in your browser +``` + +## See also + +- [bb workspace list](#bb-workspace-list) - List workspaces +- [bb workspace members](#bb-workspace-members) - List workspace members + +--- + +# bb workspace members + +List members of a workspace. + +## Synopsis + +``` +bb workspace members [workspace] [flags] +``` + +## Description + +Display a list of all members in a workspace, including their username, display name, and permission level. + +If no workspace is specified, the default workspace (if configured) is used. + +## Flags + +| Flag | Description | +|------|-------------| +| `-r, --role <role>` | Filter by role (owner, collaborator, member) | +| `-L, --limit <number>` | Maximum number of members to list (default: 30) | +| `--json` | Output in JSON format | +| `-h, --help` | Show help for command | + +## Examples + +List workspace members: + +``` +$ bb workspace members myteam +USERNAME NAME ROLE +johndoe John Doe owner +janesmith Jane Smith collaborator +bobwilson Bob Wilson member +alicebrown Alice Brown member +``` + +Filter by role: + +``` +$ bb workspace members myteam --role owner +USERNAME NAME ROLE +johndoe John Doe owner +``` + +List members of the default workspace: + +``` +$ bb workspace members +USERNAME NAME ROLE +johndoe John Doe owner +... +``` + +Output as JSON: + +``` +$ bb workspace members myteam --json +[ + { + "username": "johndoe", + "display_name": "John Doe", + "role": "owner", + "account_id": "5e5d5e5d5e5d5e5d5e5d5e5d" + }, + ... +] +``` + +## See also + +- [bb workspace list](#bb-workspace-list) - List workspaces +- [bb workspace view](#bb-workspace-view) - View workspace details diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md new file mode 100644 index 0000000..51af45d --- /dev/null +++ b/docs/guide/authentication.md @@ -0,0 +1,301 @@ +# Authentication + +This guide covers how to authenticate the `bb` CLI with Bitbucket Cloud. + +## Overview + +The `bb` CLI supports two authentication methods: + +| Method | Best For | Setup Complexity | +|--------|----------|------------------| +| **OAuth 2.0** | Interactive use, full API access | Medium (one-time setup) | +| **Repository Access Token** | CI/CD, scripts, single repo access | Easy | + +> **Important:** Bitbucket has deprecated App Passwords. Use OAuth or Repository Access Tokens instead. + +## Quick Start + +### For Interactive Use (OAuth) + +```bash +# 1. Set up OAuth consumer (one-time, see detailed instructions below) +export BB_OAUTH_CLIENT_ID="your_client_id" +export BB_OAUTH_CLIENT_SECRET="your_client_secret" + +# 2. Login +bb auth login +``` + +### For CI/CD (Repository Access Token) + +```bash +echo "$BITBUCKET_TOKEN" | bb auth login --with-token +``` + +--- + +## OAuth 2.0 Authentication + +OAuth is the recommended method for interactive use. It requires a one-time setup of an "OAuth consumer" in Bitbucket. + +### Step 1: Create an OAuth Consumer + +1. Go to your **Workspace Settings**: + ``` + https://bitbucket.org/YOUR_WORKSPACE/workspace/settings/oauth-consumers + ``` + +2. Click **"Add consumer"** + +3. Configure the consumer: + - **Name:** `bb CLI` (or any descriptive name) + - **Callback URL:** `http://localhost:8372/callback` + - **This is a private consumer:** ✓ Check this box + +4. Select **Permissions** based on what you need: + + | Permission | Commands | + |------------|----------| + | Account: Read | `bb auth status`, user info | + | Repositories: Read | `bb repo list`, `bb repo view`, `bb repo clone` | + | Repositories: Write | `bb repo create`, `bb repo fork` | + | Repositories: Admin | `bb repo delete` | + | Pull requests: Read | `bb pr list`, `bb pr view`, `bb pr diff` | + | Pull requests: Write | `bb pr create`, `bb pr merge`, `bb pr review` | + | Issues: Read | `bb issue list`, `bb issue view` | + | Issues: Write | `bb issue create`, `bb issue close` | + | Pipelines: Read | `bb pipeline list`, `bb pipeline view`, `bb pipeline logs` | + | Pipelines: Write | `bb pipeline run`, `bb pipeline stop` | + | Snippets: Read | `bb snippet list`, `bb snippet view` | + | Snippets: Write | `bb snippet create`, `bb snippet delete` | + +5. Click **"Save"** + +6. Copy the **Key** (Client ID) and **Secret** (Client Secret) shown + +### Step 2: Configure Environment Variables + +Add to your shell profile (`~/.bashrc`, `~/.zshrc`, `~/.config/fish/config.fish`): + +```bash +export BB_OAUTH_CLIENT_ID="your_key_here" +export BB_OAUTH_CLIENT_SECRET="your_secret_here" +``` + +Reload your shell: + +```bash +source ~/.zshrc # or ~/.bashrc +``` + +### Step 3: Authenticate + +```bash +bb auth login +``` + +This will: +1. Open your browser to Bitbucket's authorization page +2. Ask you to grant permissions +3. Redirect back to complete authentication +4. Store tokens securely + +### Verify + +```bash +bb auth status +``` + +### Token Refresh + +OAuth tokens expire (typically after 2 hours). The CLI automatically refreshes them using the stored refresh token. If refresh fails, re-run `bb auth login`. + +--- + +## Repository Access Tokens + +Repository Access Tokens are scoped to a single repository, making them ideal for CI/CD pipelines. + +### Create a Repository Access Token + +1. Go to your repository: + ``` + https://bitbucket.org/WORKSPACE/REPO/admin/access-tokens + ``` + +2. Click **"Create Repository Access Token"** + +3. Configure: + - **Name:** Descriptive name (e.g., `ci-pipeline`) + - **Scopes:** Select required permissions + +4. Click **"Create"** and **copy the token immediately** + +### Use the Token + +```bash +# Interactive +bb auth login --with-token +# Paste your token when prompted + +# Non-interactive (CI/CD) +echo "$BITBUCKET_TOKEN" | bb auth login --with-token + +# Or use environment variable directly (no login needed) +export BB_TOKEN="your_repository_access_token" +bb pr list +``` + +--- + +## Token Storage + +### Storage Locations + +| OS | Primary Storage | Fallback | +|----|-----------------|----------| +| macOS | Keychain | `~/.config/bb/hosts.yml` | +| Linux | Secret Service (GNOME Keyring, KWallet) | `~/.config/bb/hosts.yml` | +| Windows | Credential Manager | `%APPDATA%\bb\hosts.yml` | + +### hosts.yml Format + +```yaml +bitbucket.org: + user: yourname + oauth_token: <stored securely> +``` + +The CLI sets restrictive permissions (0600) on the credentials file. + +--- + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `BB_TOKEN` | Access token (highest priority) | +| `BITBUCKET_TOKEN` | Alternative token variable | +| `BB_OAUTH_CLIENT_ID` | OAuth consumer key | +| `BB_OAUTH_CLIENT_SECRET` | OAuth consumer secret | + +### Precedence Order + +1. `BB_TOKEN` environment variable +2. `BITBUCKET_TOKEN` environment variable +3. Stored OAuth token (from `bb auth login`) + +--- + +## CI/CD Examples + +### GitHub Actions + +```yaml +jobs: + bitbucket-sync: + runs-on: ubuntu-latest + steps: + - name: Install bb CLI + run: go install github.com/rbansal42/bitbucket-cli/cmd/bb@latest + + - name: Authenticate + run: echo "${{ secrets.BITBUCKET_TOKEN }}" | bb auth login --with-token + + - name: List PRs + run: bb pr list --repo myworkspace/myrepo +``` + +### Bitbucket Pipelines + +```yaml +pipelines: + default: + - step: + script: + - go install github.com/rbansal42/bitbucket-cli/cmd/bb@latest + - export BB_TOKEN=$REPOSITORY_ACCESS_TOKEN + - bb pipeline list +``` + +### GitLab CI + +```yaml +bitbucket-integration: + script: + - go install github.com/rbansal42/bitbucket-cli/cmd/bb@latest + - echo "$BITBUCKET_TOKEN" | bb auth login --with-token + - bb pr create --title "Sync from GitLab" +``` + +--- + +## Troubleshooting + +### "OAuth client credentials not configured" + +You haven't set up the OAuth consumer environment variables: + +```bash +export BB_OAUTH_CLIENT_ID="your_key" +export BB_OAUTH_CLIENT_SECRET="your_secret" +``` + +See [Step 1: Create an OAuth Consumer](#step-1-create-an-oauth-consumer). + +### "invalid token" error + +- Token may have expired +- Token doesn't have required permissions +- Try re-authenticating: `bb auth login` + +### "authorization failed: access_denied" + +You denied the permission request in the browser. Run `bb auth login` again and click "Grant access". + +### 401 Unauthorized + +- Token is invalid or expired +- Re-authenticate: `bb auth logout && bb auth login` + +### 403 Forbidden + +- Token doesn't have required permissions +- Check OAuth consumer permissions or create a new token with correct scopes + +--- + +## Logging Out + +Remove stored credentials: + +```bash +bb auth logout +``` + +--- + +## Security Best Practices + +### Do + +- Use **OAuth** for interactive sessions +- Use **Repository Access Tokens** (scoped to one repo) for CI/CD +- Store tokens in CI/CD **secret management** (not in code) +- **Rotate tokens** periodically +- Use **minimal permissions** for your use case + +### Don't + +- Don't commit tokens to version control +- Don't share tokens between users or systems +- Don't use overly broad permissions +- Don't store tokens in shell history + +--- + +## Related + +- [Configuration Guide](configuration.md) +- [Troubleshooting Guide](troubleshooting.md) +- [bb auth command reference](../commands/bb_auth.md) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md new file mode 100644 index 0000000..6314870 --- /dev/null +++ b/docs/guide/configuration.md @@ -0,0 +1,296 @@ +# Configuration Guide + +This guide covers all configuration options for `bb`, including file locations, settings, and environment variables. + +## Configuration File Locations + +`bb` stores its configuration in `~/.config/bb/`: + +``` +~/.config/bb/ +├── config.yml # General settings +└── hosts.yml # Authentication credentials per host +``` + +On Windows, the configuration directory is `%APPDATA%\bb\`. + +## config.yml Structure + +The main configuration file controls `bb` behavior: + +```yaml +# Default Bitbucket host (for users with Bitbucket Data Center) +default_host: bitbucket.org + +# Git protocol for cloning and remotes +git_protocol: https # or: ssh + +# Default editor for writing PR descriptions, comments, etc. +editor: vim + +# Default workspace (optional - saves typing for single-workspace users) +default_workspace: mycompany + +# Preferred pager for long output +pager: less + +# HTTP settings +http: + timeout: 30s + max_retries: 3 + +# Output preferences +output: + format: table # or: json, yaml + color: auto # or: always, never + +# Pull request defaults +pr: + default_branch: main + draft_by_default: false + +# Aliases for common commands +aliases: + co: pr checkout + ls: pr list + rv: pr review +``` + +## hosts.yml Structure + +Authentication credentials are stored separately in `hosts.yml`: + +```yaml +bitbucket.org: + user: your-username + oauth_token: your-access token-or-token + git_protocol: ssh # Override per-host + +# For Bitbucket Data Center / Server installations +bitbucket.mycompany.com: + user: jdoe + oauth_token: xxxxxxxxxxxxxx + git_protocol: https +``` + +> **Security Note:** `hosts.yml` contains sensitive credentials. Ensure it has restricted permissions (`chmod 600 ~/.config/bb/hosts.yml`). + +## Using `bb config` Commands + +### View Configuration + +```bash +# Get a specific value +bb config get git_protocol +# Output: https + +# Get a nested value +bb config get output.format +# Output: table + +# List all configuration +bb config list +``` + +### Set Configuration + +```bash +# Set a value +bb config set git_protocol ssh + +# Set a nested value +bb config set output.format json + +# Set for a specific host +bb config set -h bitbucket.mycompany.com git_protocol https +``` + +### Unset Configuration + +```bash +# Remove a configuration value (reverts to default) +bb config unset editor +``` + +## Git Protocol Preference + +`bb` supports both HTTPS and SSH for Git operations: + +```bash +# Use SSH (requires SSH key setup) +bb config set git_protocol ssh + +# Use HTTPS (requires access token) +bb config set git_protocol https +``` + +**When to use SSH:** +- You have SSH keys configured with Bitbucket +- You prefer not entering credentials for Git operations +- Your organization requires SSH + +**When to use HTTPS:** +- Simpler setup with access tokens +- Working behind corporate firewalls that block SSH +- Using Bitbucket access tokens for authentication + +The protocol affects: +- `bb repo clone` - URL used for cloning +- `bb pr checkout` - Remote URL for fetching PR branches +- `bb repo fork` - Remote URL added for your fork + +## Editor Configuration + +`bb` uses an editor for writing PR descriptions, comments, and other multi-line input: + +```bash +# Set your preferred editor +bb config set editor "code --wait" # VS Code +bb config set editor "vim" # Vim +bb config set editor "nano" # Nano +bb config set editor "subl -w" # Sublime Text +``` + +Editor resolution order: +1. `BB_EDITOR` environment variable +2. `editor` in config.yml +3. `VISUAL` environment variable +4. `EDITOR` environment variable +5. Default: `nano` (macOS/Linux) or `notepad` (Windows) + +## Environment Variables + +Environment variables override configuration file settings: + +| Variable | Description | Example | +|----------|-------------|---------| +| `BB_TOKEN` | Authentication token | `export BB_TOKEN=xxxx` | +| `BB_HOST` | Default Bitbucket host | `export BB_HOST=bitbucket.mycompany.com` | +| `BB_EDITOR` | Editor for composing text | `export BB_EDITOR="code --wait"` | +| `BB_PAGER` | Pager for long output | `export BB_PAGER=less` | +| `BB_WORKSPACE` | Default workspace | `export BB_WORKSPACE=myteam` | +| `BB_REPO` | Default repository | `export BB_REPO=myteam/myrepo` | +| `BB_NO_COLOR` | Disable colored output | `export BB_NO_COLOR=1` | +| `BB_DEBUG` | Enable debug logging | `export BB_DEBUG=1` | +| `BB_CONFIG_DIR` | Custom config directory | `export BB_CONFIG_DIR=/path/to/config` | + +### CI/CD Usage + +For CI/CD pipelines, use environment variables for authentication: + +```yaml +# Bitbucket Pipelines example +script: + - export BB_TOKEN=$BB_access token + - bb pr list --state OPEN +``` + +```yaml +# GitHub Actions example (for cross-platform tools) +env: + BB_TOKEN: ${{ secrets.BITBUCKET_TOKEN }} + BB_WORKSPACE: mycompany +``` + +## Per-Repository Configuration + +Create a `.bb.yml` file in your repository root for project-specific settings: + +```yaml +# .bb.yml - Repository-specific configuration + +# Default reviewers added to every PR +reviewers: + - alice + - bob + +# Default PR settings +pr: + default_branch: develop + title_prefix: "[PROJ]" + close_source_branch: true + +# Custom pipelines triggers +pipelines: + run_on_pr: true + +# Issue tracker integration +issues: + prefix: PROJ- + link_pattern: "https://jira.company.com/browse/{issue}" +``` + +### Supported .bb.yml Settings + +| Setting | Description | +|---------|-------------| +| `reviewers` | Default reviewers for PRs | +| `pr.default_branch` | Target branch for new PRs | +| `pr.close_source_branch` | Auto-close branch on merge | +| `pr.title_prefix` | Prefix added to PR titles | +| `pipelines.run_on_pr` | Trigger pipeline on PR creation | +| `issues.prefix` | Issue ID prefix for linking | +| `issues.link_pattern` | URL pattern for issue links | + +## Configuration Precedence + +`bb` resolves configuration in this order (highest to lowest priority): + +1. **Command-line flags** - `--workspace`, `--repo`, etc. +2. **Environment variables** - `BB_TOKEN`, `BB_WORKSPACE`, etc. +3. **Repository config** - `.bb.yml` in current repo +4. **User config** - `~/.config/bb/config.yml` +5. **Host-specific config** - Settings in `hosts.yml` +6. **Built-in defaults** + +### Example + +```bash +# config.yml has: git_protocol: https +# Environment has: BB_GIT_PROTOCOL=ssh +# Command run: bb repo clone myrepo + +# Result: Uses SSH because environment variable takes precedence +``` + +## Troubleshooting + +### View Resolved Configuration + +```bash +# Show all resolved settings with their sources +bb config list --show-source +``` + +Output: +``` +git_protocol: ssh (from: environment BB_GIT_PROTOCOL) +editor: vim (from: /Users/you/.config/bb/config.yml) +default_workspace: myteam (from: .bb.yml) +pager: less (from: default) +``` + +### Reset Configuration + +```bash +# Reset all configuration to defaults +rm -rf ~/.config/bb/config.yml + +# Reset authentication (re-run login) +rm ~/.config/bb/hosts.yml +bb auth login +``` + +### Debug Mode + +Enable verbose output to troubleshoot configuration issues: + +```bash +BB_DEBUG=1 bb pr list +``` + +This shows: +- Configuration files loaded +- Environment variables detected +- API requests and responses +- Authentication method used diff --git a/docs/guide/scripting.md b/docs/guide/scripting.md new file mode 100644 index 0000000..8124803 --- /dev/null +++ b/docs/guide/scripting.md @@ -0,0 +1,936 @@ +# Scripting and Automation Guide + +This guide covers using `bb` in scripts, CI/CD pipelines, and automation workflows. + +## Table of Contents + +- [Using bb in Shell Scripts](#using-bb-in-shell-scripts) +- [JSON Output Mode](#json-output-mode) +- [Raw API Access](#raw-api-access) +- [Common Automation Patterns](#common-automation-patterns) +- [Exit Codes and Error Handling](#exit-codes-and-error-handling) +- [Working with jq](#working-with-jq) +- [Non-Interactive Mode](#non-interactive-mode) +- [Example Scripts](#example-scripts) +- [CI/CD Integration](#cicd-integration) + +--- + +## Using bb in Shell Scripts + +The `bb` CLI is designed to work well in scripts and automation. Here are the key principles: + +### Basic Script Structure + +```bash +#!/bin/bash +set -euo pipefail + +# Authenticate using environment variable +export BB_TOKEN="your-access token" + +# Run bb commands +bb pr list --json | jq '.[] | .title' +``` + +### Authentication in Scripts + +For scripted use, set authentication via environment variables: + +```bash +# Option 1: BB_TOKEN (preferred) +export BB_TOKEN="your-bitbucket-access token" + +# Option 2: BITBUCKET_TOKEN (alternative) +export BITBUCKET_TOKEN="your-bitbucket-access token" +``` + +Create an access token in Bitbucket: +1. Go to Personal Settings > access tokens +2. Create a new access token with required permissions +3. Store it securely (environment variable, secrets manager, etc.) + +### Specifying Repository Context + +Always specify the repository explicitly in scripts to avoid dependency on git context: + +```bash +# Use -R or --repo flag +bb pr list -R myworkspace/myrepo --json + +# Or use the global flag +bb --repo myworkspace/myrepo pr list --json +``` + +--- + +## JSON Output Mode + +Most `bb` commands support `--json` flag for machine-readable output: + +```bash +# List PRs as JSON +bb pr list --json + +# List issues as JSON +bb issue list --json + +# List pipelines as JSON +bb pipeline list --json +``` + +### JSON Output Examples + +**Pull Requests:** +```bash +bb pr list --json +``` +```json +[ + { + "id": 42, + "title": "Add new feature", + "state": "OPEN", + "source": { + "branch": { + "name": "feature/new-thing" + } + }, + "destination": { + "branch": { + "name": "main" + } + }, + "author": { + "display_name": "John Doe" + } + } +] +``` + +**Issues:** +```bash +bb issue list --json +``` +```json +[ + { + "id": 1, + "title": "Bug in login flow", + "state": "open", + "kind": "bug", + "priority": "critical", + "assignee": "johndoe" + } +] +``` + +**Pipelines:** +```bash +bb pipeline list --json +``` +```json +[ + { + "build_number": 123, + "state": "COMPLETED", + "result": "SUCCESSFUL", + "branch": "main", + "commit": "abc1234", + "duration": 180 + } +] +``` + +--- + +## Raw API Access + +Use `bb api` for direct access to any Bitbucket API endpoint: + +### Basic Usage + +```bash +# GET request (default) +bb api /user + +# GET with full endpoint +bb api /repositories/myworkspace/myrepo + +# Specify HTTP method +bb api /repositories/myworkspace/myrepo/issues --method POST \ + --json title="New issue" \ + --json priority="major" +``` + +### Request Options + +```bash +# Add custom headers +bb api /user --header "Accept: application/json" + +# POST with JSON body +bb api /repositories/workspace/repo/issues \ + --method POST \ + --json title="Bug report" \ + --json content.raw="Description here" \ + --json priority="major" + +# Read request body from file +bb api /repositories/workspace/repo/src/main/config.json \ + --method PUT \ + --input config.json + +# Read from stdin +echo '{"title": "Test"}' | bb api /repositories/workspace/repo/issues \ + --method POST \ + --input - +``` + +### Pagination + +```bash +# Automatically fetch all pages +bb api /repositories/myworkspace --paginate + +# This returns all results combined into a single JSON array +``` + +### Response Options + +```bash +# Include response headers +bb api /user --include + +# Silent mode (no output, useful for checking status codes) +bb api /user --silent +``` + +### API Examples + +```bash +# Get repository details +bb api /repositories/myworkspace/myrepo + +# List workspace members +bb api /workspaces/myworkspace/members + +# Get pipeline configuration +bb api /repositories/myworkspace/myrepo/pipelines_config + +# Create a branch restriction +bb api /repositories/myworkspace/myrepo/branch-restrictions \ + --method POST \ + --json kind="push" \ + --json pattern="main" + +# Add a webhook +bb api /repositories/myworkspace/myrepo/hooks \ + --method POST \ + --json description="CI webhook" \ + --json url="https://ci.example.com/webhook" \ + --json active=true \ + --json events='["repo:push", "pullrequest:created"]' +``` + +--- + +## Common Automation Patterns + +### Auto-Creating PRs in CI/CD + +Create a PR automatically after pushing a feature branch: + +```bash +#!/bin/bash +set -euo pipefail + +BRANCH=$(git rev-parse --abbrev-ref HEAD) +BASE_BRANCH="${BASE_BRANCH:-main}" +WORKSPACE="myworkspace" +REPO="myrepo" + +# Skip if on main/master +if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then + echo "Skipping PR creation on main branch" + exit 0 +fi + +# Check if PR already exists +EXISTING_PR=$(bb pr list -R "$WORKSPACE/$REPO" --json | \ + jq -r --arg branch "$BRANCH" '.[] | select(.source.branch.name == $branch) | .id') + +if [[ -n "$EXISTING_PR" ]]; then + echo "PR #$EXISTING_PR already exists for branch $BRANCH" + exit 0 +fi + +# Create PR with auto-filled content from commits +bb pr create -R "$WORKSPACE/$REPO" \ + --fill \ + --base "$BASE_BRANCH" \ + --head "$BRANCH" +``` + +### Batch Operations on Issues + +Close all issues with a specific label: + +```bash +#!/bin/bash +set -euo pipefail + +WORKSPACE="myworkspace" +REPO="myrepo" + +# Get all open issues of a certain kind +ISSUE_IDS=$(bb issue list -R "$WORKSPACE/$REPO" --state open --kind bug --json | \ + jq -r '.[].id') + +for id in $ISSUE_IDS; do + echo "Closing issue #$id" + bb issue close -R "$WORKSPACE/$REPO" "$id" +done +``` + +Bulk update issue priority: + +```bash +#!/bin/bash +set -euo pipefail + +WORKSPACE="myworkspace" +REPO="myrepo" + +# Get all trivial priority issues +bb issue list -R "$WORKSPACE/$REPO" --priority trivial --json | \ + jq -r '.[].id' | while read -r id; do + echo "Updating issue #$id to minor priority" + bb issue edit -R "$WORKSPACE/$REPO" "$id" --priority minor +done +``` + +### Pipeline Triggering from Scripts + +Trigger and monitor pipelines: + +```bash +#!/bin/bash +set -euo pipefail + +WORKSPACE="myworkspace" +REPO="myrepo" +BRANCH="${1:-main}" + +# Trigger pipeline +echo "Triggering pipeline on branch $BRANCH..." +bb pipeline run -R "$WORKSPACE/$REPO" --branch "$BRANCH" + +# Wait for pipeline to complete (with timeout) +TIMEOUT=1800 # 30 minutes +ELAPSED=0 +INTERVAL=30 + +while [[ $ELAPSED -lt $TIMEOUT ]]; do + # Get latest pipeline status + STATUS=$(bb pipeline list -R "$WORKSPACE/$REPO" --branch "$BRANCH" --limit 1 --json | \ + jq -r '.[0].state') + RESULT=$(bb pipeline list -R "$WORKSPACE/$REPO" --branch "$BRANCH" --limit 1 --json | \ + jq -r '.[0].result // empty') + + echo "Pipeline status: $STATUS ${RESULT:+($RESULT)}" + + if [[ "$STATUS" == "COMPLETED" ]]; then + if [[ "$RESULT" == "SUCCESSFUL" ]]; then + echo "Pipeline completed successfully!" + exit 0 + else + echo "Pipeline failed with result: $RESULT" + exit 1 + fi + fi + + sleep $INTERVAL + ELAPSED=$((ELAPSED + INTERVAL)) +done + +echo "Pipeline timed out after ${TIMEOUT}s" +exit 1 +``` + +Run custom pipeline with variables: + +```bash +#!/bin/bash +# Trigger a deployment pipeline + +WORKSPACE="myworkspace" +REPO="myrepo" +ENVIRONMENT="${1:-staging}" + +bb pipeline run -R "$WORKSPACE/$REPO" \ + --branch main \ + --custom "deploy-$ENVIRONMENT" +``` + +--- + +## Exit Codes and Error Handling + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | General error (command failed) | + +### Handling Errors in Scripts + +```bash +#!/bin/bash +set -euo pipefail + +# Method 1: Check exit code explicitly +if ! bb pr create --title "Test" --body "Test body"; then + echo "Failed to create PR" + exit 1 +fi + +# Method 2: Capture output and check +OUTPUT=$(bb pr list --json 2>&1) || { + echo "Failed to list PRs: $OUTPUT" + exit 1 +} + +# Method 3: Use trap for cleanup +cleanup() { + echo "Script failed, cleaning up..." +} +trap cleanup ERR + +bb pr create --title "Test" +``` + +### Checking for Empty Results + +```bash +#!/bin/bash + +# Check if any PRs exist +PR_COUNT=$(bb pr list --json | jq 'length') + +if [[ "$PR_COUNT" -eq 0 ]]; then + echo "No open PRs found" + exit 0 +fi + +echo "Found $PR_COUNT open PRs" +``` + +### Validating API Responses + +```bash +#!/bin/bash + +# Check if API call succeeded and returned expected data +RESPONSE=$(bb api /user 2>&1) +EXIT_CODE=$? + +if [[ $EXIT_CODE -ne 0 ]]; then + echo "API call failed: $RESPONSE" + exit 1 +fi + +USERNAME=$(echo "$RESPONSE" | jq -r '.username // empty') +if [[ -z "$USERNAME" ]]; then + echo "Unexpected API response format" + exit 1 +fi + +echo "Logged in as: $USERNAME" +``` + +--- + +## Working with jq + +### Common jq Patterns + +**Extract specific fields:** +```bash +# Get PR titles +bb pr list --json | jq -r '.[].title' + +# Get PR IDs and titles +bb pr list --json | jq -r '.[] | "\(.id): \(.title)"' +``` + +**Filter results:** +```bash +# PRs by specific author +bb pr list --json | jq '[.[] | select(.author.display_name == "John Doe")]' + +# Issues with high priority +bb issue list --json | jq '[.[] | select(.priority == "critical" or .priority == "blocker")]' + +# Failed pipelines +bb pipeline list --json | jq '[.[] | select(.result == "FAILED")]' +``` + +**Transform output:** +```bash +# Create CSV from PRs +bb pr list --json | jq -r '.[] | [.id, .title, .state] | @csv' + +# Create markdown list +bb pr list --json | jq -r '.[] | "- [\(.title)](\(.url))"' +``` + +**Aggregate data:** +```bash +# Count PRs by state +bb pr list --json | jq 'group_by(.state) | map({state: .[0].state, count: length})' + +# Sum pipeline durations +bb pipeline list --json | jq '[.[].duration] | add' +``` + +**Complex transformations:** +```bash +# Get PR summary with reviewers +bb pr list --json | jq ' + .[] | { + id, + title, + branch: .source.branch.name, + author: .author.display_name, + created: .created_on + } +' +``` + +--- + +## Non-Interactive Mode + +When running in non-interactive environments (CI/CD, cron jobs), ensure all inputs are provided via flags: + +### PR Creation + +```bash +# Fully non-interactive PR creation +bb pr create \ + --title "Automated PR: Update dependencies" \ + --body "This PR was created automatically by CI." \ + --base main \ + --head feature/deps-update \ + --repo myworkspace/myrepo +``` + +### Issue Creation + +```bash +# Fully non-interactive issue creation +bb issue create \ + --title "Automated: Performance regression detected" \ + --body "Performance dropped by 15% in latest build." \ + --kind bug \ + --priority critical \ + --repo myworkspace/myrepo +``` + +### Detecting Non-Interactive Mode + +The `bb` CLI automatically detects when stdin is not a TTY and will error if required input is missing: + +```bash +# This will fail in non-interactive mode without --title +echo "" | bb pr create +# Error: --title flag is required when not running interactively +``` + +### Environment Variables + +Disable color output in scripts: +```bash +export NO_COLOR=1 +# or +export BB_NO_COLOR=1 +``` + +--- + +## Example Scripts + +### Daily PR Summary Report + +```bash +#!/bin/bash +# Generate a daily summary of open PRs + +set -euo pipefail + +WORKSPACE="myworkspace" +REPO="myrepo" +OUTPUT_FILE="pr-summary-$(date +%Y%m%d).md" + +cat > "$OUTPUT_FILE" << EOF +# Pull Request Summary - $(date +%Y-%m-%d) + +## Open PRs + +EOF + +bb pr list -R "$WORKSPACE/$REPO" --state OPEN --json | jq -r ' + .[] | "### PR #\(.id): \(.title)\n- **Author:** \(.author.display_name)\n- **Branch:** \(.source.branch.name) -> \(.destination.branch.name)\n- **Created:** \(.created_on)\n" +' >> "$OUTPUT_FILE" + +echo "Summary written to $OUTPUT_FILE" +``` + +### Stale PR Reminder + +```bash +#!/bin/bash +# Find PRs older than 7 days without recent activity + +set -euo pipefail + +WORKSPACE="myworkspace" +REPO="myrepo" +DAYS_OLD=7 +CUTOFF_DATE=$(date -d "$DAYS_OLD days ago" +%Y-%m-%dT%H:%M:%S 2>/dev/null || \ + date -v-${DAYS_OLD}d +%Y-%m-%dT%H:%M:%S) + +echo "Finding PRs older than $DAYS_OLD days..." + +bb pr list -R "$WORKSPACE/$REPO" --state OPEN --json | jq -r --arg cutoff "$CUTOFF_DATE" ' + .[] | select(.updated_on < $cutoff) | + "PR #\(.id): \(.title) (last updated: \(.updated_on))" +' +``` + +### Release Automation + +```bash +#!/bin/bash +# Create a release PR merging develop into main + +set -euo pipefail + +WORKSPACE="myworkspace" +REPO="myrepo" +VERSION="${1:?Usage: $0 <version>}" +RELEASE_BRANCH="release/$VERSION" + +echo "Creating release $VERSION..." + +# Create release branch +git checkout develop +git pull origin develop +git checkout -b "$RELEASE_BRANCH" +git push -u origin "$RELEASE_BRANCH" + +# Create PR +bb pr create -R "$WORKSPACE/$REPO" \ + --title "Release $VERSION" \ + --body "## Release $VERSION + +### Changes +$(git log --oneline main..HEAD | sed 's/^/- /') + +### Checklist +- [ ] Version bumped +- [ ] Changelog updated +- [ ] Tests passing" \ + --base main \ + --head "$RELEASE_BRANCH" + +echo "Release PR created!" +``` + +### Sync Fork with Upstream + +```bash +#!/bin/bash +# Sync a forked repository with upstream + +set -euo pipefail + +UPSTREAM_WORKSPACE="upstream-workspace" +UPSTREAM_REPO="upstream-repo" +FORK_WORKSPACE="my-workspace" +FORK_REPO="my-fork" + +# Get latest from upstream using API +LATEST_SHA=$(bb api /repositories/$UPSTREAM_WORKSPACE/$UPSTREAM_REPO/refs/branches/main | \ + jq -r '.target.hash') + +echo "Latest upstream commit: $LATEST_SHA" + +# Sync fork (via git) +git fetch upstream +git checkout main +git merge upstream/main +git push origin main + +echo "Fork synced!" +``` + +### Monitor Pipeline and Notify + +```bash +#!/bin/bash +# Monitor pipeline and send notification on completion + +set -euo pipefail + +WORKSPACE="myworkspace" +REPO="myrepo" +SLACK_WEBHOOK="${SLACK_WEBHOOK_URL:-}" +BUILD_NUM="${1:?Usage: $0 <build-number>}" + +wait_for_pipeline() { + local build=$1 + local timeout=3600 + local elapsed=0 + + while [[ $elapsed -lt $timeout ]]; do + local status=$(bb pipeline list -R "$WORKSPACE/$REPO" --json | \ + jq -r --argjson num "$build" '.[] | select(.build_number == $num) | .state') + local result=$(bb pipeline list -R "$WORKSPACE/$REPO" --json | \ + jq -r --argjson num "$build" '.[] | select(.build_number == $num) | .result // empty') + + if [[ "$status" == "COMPLETED" ]]; then + echo "$result" + return 0 + fi + + sleep 30 + elapsed=$((elapsed + 30)) + done + + echo "TIMEOUT" + return 1 +} + +RESULT=$(wait_for_pipeline "$BUILD_NUM") + +# Send Slack notification if webhook configured +if [[ -n "$SLACK_WEBHOOK" ]]; then + if [[ "$RESULT" == "SUCCESSFUL" ]]; then + COLOR="good" + TEXT="Pipeline #$BUILD_NUM completed successfully!" + else + COLOR="danger" + TEXT="Pipeline #$BUILD_NUM failed: $RESULT" + fi + + curl -s -X POST "$SLACK_WEBHOOK" \ + -H 'Content-type: application/json' \ + -d "{\"attachments\":[{\"color\":\"$COLOR\",\"text\":\"$TEXT\"}]}" +fi + +[[ "$RESULT" == "SUCCESSFUL" ]] +``` + +--- + +## CI/CD Integration + +### GitHub Actions + +```yaml +name: Bitbucket Sync + +on: + push: + branches: [main] + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install bb CLI + run: | + curl -sL https://github.com/rbansal42/bitbucket-cli/releases/latest/download/bb_linux_amd64.tar.gz | tar xz + sudo mv bb /usr/local/bin/ + + - name: Create PR in Bitbucket + env: + BB_TOKEN: ${{ secrets.BITBUCKET_access token }} + run: | + bb pr create \ + --repo myworkspace/myrepo \ + --title "Sync from GitHub: ${{ github.sha }}" \ + --body "Automated sync from GitHub repository" \ + --base main \ + --head sync-${{ github.sha }} +``` + +### GitLab CI + +```yaml +stages: + - deploy + +variables: + BB_TOKEN: $BITBUCKET_access token + +deploy: + stage: deploy + image: alpine:latest + before_script: + - apk add --no-cache curl jq + - curl -sL https://github.com/rbansal42/bitbucket-cli/releases/latest/download/bb_linux_amd64.tar.gz | tar xz + - mv bb /usr/local/bin/ + script: + - | + bb pipeline run \ + --repo myworkspace/myrepo \ + --branch main \ + --custom deploy-production + only: + - main +``` + +### Jenkins Pipeline + +```groovy +pipeline { + agent any + + environment { + BB_TOKEN = credentials('bitbucket-access token') + } + + stages { + stage('Setup') { + steps { + sh ''' + curl -sL https://github.com/rbansal42/bitbucket-cli/releases/latest/download/bb_linux_amd64.tar.gz | tar xz + chmod +x bb + ''' + } + } + + stage('Create PR') { + steps { + sh ''' + ./bb pr create \ + --repo myworkspace/myrepo \ + --title "Jenkins Build #${BUILD_NUMBER}" \ + --body "Automated PR from Jenkins" \ + --fill + ''' + } + } + + stage('Trigger Pipeline') { + steps { + sh ''' + ./bb pipeline run \ + --repo myworkspace/myrepo \ + --branch ${BRANCH_NAME} + ''' + } + } + } +} +``` + +### CircleCI + +```yaml +version: 2.1 + +jobs: + bitbucket-sync: + docker: + - image: cimg/base:stable + steps: + - checkout + - run: + name: Install bb CLI + command: | + curl -sL https://github.com/rbansal42/bitbucket-cli/releases/latest/download/bb_linux_amd64.tar.gz | tar xz + sudo mv bb /usr/local/bin/ + - run: + name: Sync to Bitbucket + command: | + bb pr create \ + --repo myworkspace/myrepo \ + --title "CircleCI Sync: ${CIRCLE_SHA1:0:7}" \ + --body "Automated sync from CircleCI build #${CIRCLE_BUILD_NUM}" + +workflows: + sync: + jobs: + - bitbucket-sync: + context: bitbucket-credentials +``` + +### Azure DevOps Pipeline + +```yaml +trigger: + - main + +pool: + vmImage: 'ubuntu-latest' + +variables: + - group: bitbucket-credentials + +steps: + - script: | + curl -sL https://github.com/rbansal42/bitbucket-cli/releases/latest/download/bb_linux_amd64.tar.gz | tar xz + sudo mv bb /usr/local/bin/ + displayName: 'Install bb CLI' + + - script: | + bb pipeline run \ + --repo $(BITBUCKET_WORKSPACE)/$(BITBUCKET_REPO) \ + --branch main + displayName: 'Trigger Bitbucket Pipeline' + env: + BB_TOKEN: $(BITBUCKET_access token) +``` + +### Bitbucket Pipelines (Self-Triggering) + +```yaml +# bitbucket-pipelines.yml +pipelines: + custom: + create-release-pr: + - step: + name: Create Release PR + script: + - curl -sL https://github.com/rbansal42/bitbucket-cli/releases/latest/download/bb_linux_amd64.tar.gz | tar xz + - chmod +x bb + - | + ./bb pr create \ + --repo $BITBUCKET_WORKSPACE/$BITBUCKET_REPO_SLUG \ + --title "Release $(date +%Y.%m.%d)" \ + --body "Automated release PR" \ + --base main \ + --head develop + services: + - docker +``` + +--- + +## Best Practices + +1. **Always use `--repo` in scripts** - Don't rely on git context detection +2. **Use `--json` for parsing** - Human-readable output may change between versions +3. **Handle errors gracefully** - Check exit codes and validate JSON responses +4. **Use environment variables for secrets** - Never hardcode tokens +5. **Set timeouts** - Especially for pipeline monitoring scripts +6. **Use `set -euo pipefail`** - Fail fast on errors in bash scripts +7. **Log actions** - Include echo statements for debugging +8. **Test locally first** - Verify scripts before running in CI/CD diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md new file mode 100644 index 0000000..d9f6804 --- /dev/null +++ b/docs/guide/troubleshooting.md @@ -0,0 +1,553 @@ +# Troubleshooting + +This guide covers common issues you may encounter when using the `bb` CLI and how to resolve them. + +## Authentication Problems + +### "not authenticated" Error + +**Problem:** You see an error like `error: not authenticated. Run 'bb auth login' to authenticate.` + +**Solutions:** + +1. Run the login command: + ```bash + bb auth login + ``` + +2. Verify your authentication status: + ```bash + bb auth status + ``` + +3. If you have multiple accounts, ensure you're using the correct one: + ```bash + bb auth status --show-token + ``` + +### Token Expired + +**Problem:** Commands fail with authentication errors even though you previously logged in. + +**Solutions:** + +1. Re-authenticate to refresh your token: + ```bash + bb auth login + ``` + +2. If using an access token, generate a new one in Bitbucket: + - Go to **Personal Settings** > **access tokens** + - Create a new access token with the required permissions + - Run `bb auth login` and enter the new password + +### Wrong Permissions + +**Problem:** You see `403 Forbidden` errors or "insufficient permissions" messages. + +**Solutions:** + +1. Check your current token's scopes: + ```bash + bb auth status + ``` + +2. Re-authenticate with the required permissions: + ```bash + bb auth login --scopes repository,pullrequest,pipeline + ``` + +3. For access tokens, ensure these permissions are enabled: + - **Repository:** Read, Write + - **Pull requests:** Read, Write + - **Pipelines:** Read, Write + - **Account:** Read + +--- + +## Repository Detection Issues + +### "could not detect repository" Error + +**Problem:** You see `error: could not detect repository. Run this command from a git repository or use --repo flag.` + +**Solutions:** + +1. Ensure you're in a git repository: + ```bash + git status + ``` + +2. Check if the remote is configured: + ```bash + git remote -v + ``` + +3. Explicitly specify the repository: + ```bash + bb pr list --repo workspace/repo-name + ``` + +4. If the remote URL is non-standard, set it manually: + ```bash + git remote set-url origin git@bitbucket.org:workspace/repo.git + ``` + +### Non-Bitbucket Remotes + +**Problem:** The CLI doesn't recognize your repository because the remote points to a different host. + +**Solutions:** + +1. Add a Bitbucket remote: + ```bash + git remote add bitbucket git@bitbucket.org:workspace/repo.git + ``` + +2. Use the `--repo` flag to specify the Bitbucket repository: + ```bash + bb pr list --repo workspace/repo-name + ``` + +3. Configure `bb` to use a specific remote: + ```bash + bb config set remote bitbucket + ``` + +--- + +## API Errors + +### Rate Limiting + +**Problem:** You see `error: API rate limit exceeded` or HTTP 429 errors. + +**Solutions:** + +1. Wait for the rate limit to reset (usually 1 hour) + +2. Check your current rate limit status: + ```bash + bb api /user --include-headers | grep -i rate + ``` + +3. Reduce the frequency of API calls in scripts: + ```bash + # Add delays between calls + for repo in repo1 repo2 repo3; do + bb pr list --repo "workspace/$repo" + sleep 2 + done + ``` + +### 404 Not Found + +**Problem:** You see `error: HTTP 404: Not Found` when accessing a resource. + +**Solutions:** + +1. Verify the repository exists and you have access: + ```bash + bb repo view workspace/repo-name + ``` + +2. Check for typos in workspace or repository names (they are case-sensitive) + +3. Ensure the resource (PR, issue, pipeline) exists: + ```bash + bb pr view 123 # Check if PR #123 exists + ``` + +4. Confirm you have access to private repositories + +### 403 Forbidden + +**Problem:** You see `error: HTTP 403: Forbidden` when performing an action. + +**Solutions:** + +1. Verify you have the required permissions on the repository + +2. Re-authenticate with broader scopes: + ```bash + bb auth login --scopes repository:admin,pullrequest:write + ``` + +3. Check if the repository has branch restrictions preventing your action + +4. For workspace-level operations, ensure you have workspace admin access + +--- + +## Network Issues + +### Proxy Configuration + +**Problem:** Connections fail when behind a corporate proxy. + +**Solutions:** + +1. Set the proxy environment variables: + ```bash + export HTTP_PROXY=http://proxy.example.com:8080 + export HTTPS_PROXY=http://proxy.example.com:8080 + export NO_PROXY=localhost,127.0.0.1 + ``` + +2. Configure git to use the proxy: + ```bash + git config --global http.proxy http://proxy.example.com:8080 + git config --global https.proxy http://proxy.example.com:8080 + ``` + +3. For authenticated proxies: + ```bash + export HTTPS_PROXY=http://username:password@proxy.example.com:8080 + ``` + +### SSL/TLS Errors + +**Problem:** You see certificate verification errors like `SSL certificate problem: unable to get local issuer certificate`. + +**Solutions:** + +1. Update your CA certificates: + ```bash + # macOS + brew install ca-certificates + + # Ubuntu/Debian + sudo apt-get update && sudo apt-get install ca-certificates + + # Fedora/RHEL + sudo dnf install ca-certificates + ``` + +2. If using a corporate CA, add it to your trust store: + ```bash + # macOS + sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain corp-ca.crt + + # Linux + sudo cp corp-ca.crt /usr/local/share/ca-certificates/ + sudo update-ca-certificates + ``` + +3. **Not recommended for production:** Skip certificate verification: + ```bash + bb config set insecure true + ``` + +--- + +## Git-Related Issues + +### SSH vs HTTPS Problems + +**Problem:** Git operations fail with authentication errors. + +**Solutions:** + +1. Check which protocol your remote uses: + ```bash + git remote -v + ``` + +2. Switch from HTTPS to SSH: + ```bash + git remote set-url origin git@bitbucket.org:workspace/repo.git + ``` + +3. Switch from SSH to HTTPS: + ```bash + git remote set-url origin https://bitbucket.org/workspace/repo.git + ``` + +4. For SSH, ensure your key is added to the agent: + ```bash + ssh-add -l # List loaded keys + ssh-add ~/.ssh/id_ed25519 # Add your key + ``` + +5. Test SSH connectivity: + ```bash + ssh -T git@bitbucket.org + ``` + +### Clone Failures + +**Problem:** `bb repo clone` fails to clone a repository. + +**Solutions:** + +1. Verify you have access to the repository: + ```bash + bb repo view workspace/repo-name + ``` + +2. Check your SSH configuration: + ```bash + ssh -vT git@bitbucket.org + ``` + +3. Try cloning with HTTPS instead: + ```bash + bb repo clone workspace/repo-name --protocol https + ``` + +4. Ensure you have enough disk space + +5. For large repositories, try a shallow clone: + ```bash + bb repo clone workspace/repo-name -- --depth 1 + ``` + +--- + +## Pipeline Issues + +### Pipeline Not Triggering + +**Problem:** Push events don't trigger pipelines as expected. + +**Solutions:** + +1. Verify pipelines are enabled for the repository: + ```bash + bb repo view --json | jq '.pipelines_enabled' + ``` + +2. Check if `bitbucket-pipelines.yml` exists and is valid: + ```bash + bb pipeline validate + ``` + +3. View recent pipeline runs: + ```bash + bb pipeline list + ``` + +4. Manually trigger a pipeline: + ```bash + bb pipeline run --branch main + ``` + +5. Check branch patterns in your pipeline configuration match your branch name + +### Log Viewing Problems + +**Problem:** Unable to view pipeline logs or logs appear incomplete. + +**Solutions:** + +1. Ensure the pipeline has completed or is running: + ```bash + bb pipeline view 123 + ``` + +2. View logs for a specific step: + ```bash + bb pipeline logs 123 --step "Build" + ``` + +3. For long logs, use pagination: + ```bash + bb pipeline logs 123 --step "Build" | less + ``` + +4. Download full logs: + ```bash + bb pipeline logs 123 --step "Build" > build.log + ``` + +--- + +## Getting Debug Output + +Enable verbose output to diagnose issues: + +```bash +# Basic verbose output +bb --verbose pr list + +# Full debug output including HTTP requests +bb --debug pr list + +# Log to a file for sharing +bb --debug pr list 2>&1 | tee debug.log +``` + +Debug output includes: +- HTTP request/response details +- API endpoints being called +- Authentication method used +- Configuration values + +Environment variable alternative: +```bash +export BB_DEBUG=1 +bb pr list +``` + +--- + +## Reporting Bugs + +If you encounter a bug, please report it with the following information: + +### 1. Gather System Information + +```bash +bb --version +git --version +uname -a # or systeminfo on Windows +``` + +### 2. Reproduce with Debug Output + +```bash +bb --debug <command-that-fails> 2>&1 | tee bug-report.log +``` + +### 3. Redact Sensitive Information + +Before sharing logs, remove: +- Authentication tokens +- Private repository names (if sensitive) +- Personal information + +```bash +# Redact tokens from debug output +sed -i 's/Bearer [A-Za-z0-9_-]*/Bearer [REDACTED]/g' bug-report.log +``` + +### 4. Submit the Report + +Open an issue on the project repository with: +- Description of the problem +- Steps to reproduce +- Expected behavior +- Actual behavior +- Debug output (redacted) +- System information + +--- + +## FAQ + +### How do I update bb to the latest version? + +```bash +# If installed via Homebrew +brew upgrade bb + +# If installed via go install +go install github.com/your-org/bb@latest + +# Check current version +bb --version +``` + +### How do I authenticate with multiple Bitbucket accounts? + +```bash +# Login to different accounts +bb auth login --hostname bitbucket.org --user work-account +bb auth login --hostname bitbucket.org --user personal-account + +# Switch between accounts +bb auth switch work-account + +# List all authenticated accounts +bb auth status --all +``` + +### How do I use bb in CI/CD environments? + +Set the `BB_TOKEN` environment variable: + +```bash +export BB_TOKEN=your-access token +bb pr list --repo workspace/repo +``` + +Or use `BB_USERNAME` and `BB_access token`: + +```bash +export BB_USERNAME=your-username +export BB_access token=your-access token +``` + +### How do I configure bb for my organization? + +Create a configuration file at `~/.config/bb/config.yml`: + +```yaml +default_workspace: my-workspace +default_protocol: ssh +editor: vim +pager: less +``` + +Or use `bb config`: + +```bash +bb config set default_workspace my-workspace +bb config set default_protocol ssh +``` + +### Why is bb slow? + +Common causes and solutions: + +1. **Network latency:** Use `--cache` for repeated queries + ```bash + bb pr list --cache + ``` + +2. **Large responses:** Use filters to reduce data + ```bash + bb pr list --state open --limit 10 + ``` + +3. **Debug to identify bottlenecks:** + ```bash + bb --debug pr list 2>&1 | grep "took" + ``` + +### How do I reset bb to default settings? + +```bash +# Remove configuration +rm -rf ~/.config/bb + +# Remove cached credentials (be careful!) +bb auth logout --all + +# Start fresh +bb auth login +``` + +### Can I use bb with Bitbucket Server (self-hosted)? + +Yes, specify your server's hostname: + +```bash +bb auth login --hostname bitbucket.mycompany.com +``` + +Then use the `--hostname` flag or set it in config: + +```bash +bb config set default_hostname bitbucket.mycompany.com +``` + +### How do I contribute to bb? + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests: `make test` +5. Submit a pull request + +See the CONTRIBUTING.md file in the repository for detailed guidelines. diff --git a/docs/plans/2026-02-06-dedup-refactor-and-dockerfile.md b/docs/plans/2026-02-06-dedup-refactor-and-dockerfile.md new file mode 100644 index 0000000..bf505c8 --- /dev/null +++ b/docs/plans/2026-02-06-dedup-refactor-and-dockerfile.md @@ -0,0 +1,298 @@ +# Code Deduplication, Dockerfile & Cleanup - Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Eliminate all code duplication across command packages by extracting shared utilities into `cmdutil`, remove duplicate type definitions in the `pr` package, and add a production-ready Dockerfile. + +**Architecture:** Three phases executed sequentially. Phase 1 extracts 5 shared utility functions + 2 shared output helpers into `cmdutil`. Phase 2 eliminates duplicate type definitions in `internal/cmd/pr/shared.go` by reusing `api` package types. Phase 3 adds a multi-stage Dockerfile. + +**Tech Stack:** Go 1.25.7, Docker (multi-stage build), existing `cmdutil` package + +--- + +## Phase 1: Extract Shared Utilities into `cmdutil` + +### Task 1: Add `TimeAgo` and `FormatTimeAgoString` to `cmdutil` + +**Files:** +- Create: `internal/cmdutil/time.go` +- Create: `internal/cmdutil/time_test.go` + +**What:** Extract the `timeAgo(t time.Time) string` function that is duplicated in: +- `internal/cmd/pr/view.go:279-316` +- `internal/cmd/issue/shared.go:100-137` +- `internal/cmd/pipeline/shared.go:102-143` +- `internal/cmd/repo/list.go:169-211` + +The canonical version should handle both `time.Time` input and string input (for `snippet/list.go`'s `formatTime`), and include the `.IsZero()` guard from the pipeline version. + +```go +// internal/cmdutil/time.go +package cmdutil + +import ( + "fmt" + "time" +) + +// TimeAgo returns a human-readable relative time string for a time.Time value. +// Returns "-" for zero time values. +func TimeAgo(t time.Time) string { + if t.IsZero() { + return "-" + } + + duration := time.Since(t) + + switch { + case duration < time.Minute: + return "just now" + case duration < time.Hour: + mins := int(duration.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + case duration < 24*time.Hour: + hours := int(duration.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + case duration < 30*24*time.Hour: + days := int(duration.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + case duration < 365*24*time.Hour: + months := int(duration.Hours() / 24 / 30) + if months == 1 { + return "1 month ago" + } + return fmt.Sprintf("%d months ago", months) + default: + years := int(duration.Hours() / 24 / 365) + if years == 1 { + return "1 year ago" + } + return fmt.Sprintf("%d years ago", years) + } +} + +// TimeAgoFromString parses an ISO 8601 / RFC3339 timestamp string and returns +// a human-readable relative time. Returns the raw string on parse failure. +func TimeAgoFromString(isoTime string) string { + if isoTime == "" { + return "-" + } + + t, err := time.Parse(time.RFC3339, isoTime) + if err != nil { + t, err = time.Parse("2006-01-02T15:04:05.000000-07:00", isoTime) + if err != nil { + return isoTime + } + } + + return TimeAgo(t) +} +``` + +### Task 2: Add `GetUserDisplayName` to `cmdutil` + +**Files:** +- Create: `internal/cmdutil/user.go` + +**What:** Extract the `getUserDisplayName` function duplicated in: +- `internal/cmd/pr/view.go:265-276` (takes `PRUser` value) +- `internal/cmd/issue/shared.go:151-162` (takes `*api.User` pointer) + +The canonical version uses `*api.User` since that's the API-level type. + +```go +// internal/cmdutil/user.go +package cmdutil + +import "github.com/rbansal42/bitbucket-cli/internal/api" + +// GetUserDisplayName returns the best available display name for a user. +// Returns "-" if user is nil, falls back to Username, then "unknown". +func GetUserDisplayName(user *api.User) string { + if user == nil { + return "-" + } + if user.DisplayName != "" { + return user.DisplayName + } + if user.Username != "" { + return user.Username + } + return "unknown" +} +``` + +### Task 3: Add `PrintJSON` and `PrintTableHeader` to `cmdutil` + +**Files:** +- Create: `internal/cmdutil/output.go` + +**What:** Extract the repeated JSON output pattern (10+ copies) and table header pattern (7 copies). + +```go +// internal/cmdutil/output.go +package cmdutil + +import ( + "encoding/json" + "fmt" + + "github.com/rbansal42/bitbucket-cli/internal/iostreams" +) + +// PrintJSON marshals v as indented JSON and writes it to streams.Out. +func PrintJSON(streams *iostreams.IOStreams, v interface{}) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Fprintln(streams.Out, string(data)) + return nil +} + +// PrintTableHeader writes a bold header line if color is enabled. +func PrintTableHeader(streams *iostreams.IOStreams, w *tabwriter.Writer, header string) { + if streams.ColorEnabled() { + fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) + } else { + fmt.Fprintln(w, header) + } +} +``` + +### Task 4: Add `ConfirmPrompt` to `cmdutil` + +**Files:** +- Modify: `internal/cmdutil/output.go` (add to same file) + +**What:** Extract the confirmation prompt duplicated in: +- `internal/cmd/issue/shared.go:165-172` +- `internal/cmd/snippet/delete.go:86-93` (inline) + +```go +// ConfirmPrompt reads a line from reader and returns true if user typed y/yes. +func ConfirmPrompt(reader io.Reader) bool { + scanner := bufio.NewScanner(reader) + if scanner.Scan() { + input := strings.TrimSpace(strings.ToLower(scanner.Text())) + return input == "y" || input == "yes" + } + return false +} +``` + +### Task 5: Replace all local `truncateString` with `cmdutil.TruncateString` + +**Files to modify:** +- `internal/cmd/pr/list.go` -- remove `truncateString`, use `cmdutil.TruncateString` +- `internal/cmd/issue/shared.go` -- remove `truncateString`, use `cmdutil.TruncateString` +- `internal/cmd/pipeline/shared.go` -- remove `truncateString`, use `cmdutil.TruncateString` +- `internal/cmd/repo/list.go` -- remove `truncateString`, use `cmdutil.TruncateString` +- `internal/cmd/branch/list.go` -- remove `truncateMessage`, use `cmdutil.TruncateString` + +### Task 6: Replace all local `timeAgo`/`formatTimeAgo`/`formatUpdated`/`formatTime` with `cmdutil.TimeAgo` + +**Files to modify:** +- `internal/cmd/pr/view.go` -- remove `timeAgo`, use `cmdutil.TimeAgo` +- `internal/cmd/issue/shared.go` -- remove `timeAgo`, use `cmdutil.TimeAgo` +- `internal/cmd/pipeline/shared.go` -- remove `formatTimeAgo`, use `cmdutil.TimeAgo` +- `internal/cmd/repo/list.go` -- remove `formatUpdated`, use `cmdutil.TimeAgo` +- `internal/cmd/snippet/list.go` -- remove `formatTime`, use `cmdutil.TimeAgoFromString` + +### Task 7: Replace all local `getUserDisplayName` with `cmdutil.GetUserDisplayName` + +**Files to modify:** +- `internal/cmd/issue/shared.go` -- remove `getUserDisplayName`, use `cmdutil.GetUserDisplayName` +- NOTE: `internal/cmd/pr/view.go` uses `PRUser` type -- this is fixed in Phase 2 + +### Task 8: Replace all JSON output boilerplate with `cmdutil.PrintJSON` + +**Files to modify (list commands):** +- `internal/cmd/pr/list.go` -- use `cmdutil.PrintJSON` +- `internal/cmd/branch/list.go` -- use `cmdutil.PrintJSON` +- `internal/cmd/repo/list.go` -- use `cmdutil.PrintJSON` +- `internal/cmd/snippet/list.go` -- use `cmdutil.PrintJSON` +- `internal/cmd/pr/view.go` -- use `cmdutil.PrintJSON` + +### Task 9: Replace all table header boilerplate with `cmdutil.PrintTableHeader` + +**Files to modify:** +- `internal/cmd/pr/list.go` +- `internal/cmd/issue/list.go` +- `internal/cmd/pipeline/list.go` +- `internal/cmd/repo/list.go` +- `internal/cmd/branch/list.go` +- `internal/cmd/workspace/list.go` +- `internal/cmd/snippet/list.go` + +--- + +## Phase 2: Eliminate Duplicate Types in PR Package + +### Task 10: Remove duplicate `PRUser`, `PRParticipant`, `PullRequest`, `PRComment` from `internal/cmd/pr/shared.go` + +**Files to modify:** +- `internal/cmd/pr/shared.go` -- Remove types `PRUser` (lines 98-112), `PRParticipant` (lines 114-120), `PullRequest` (lines 122-163), `PRComment` (lines 166-176), and `getPullRequest` (lines 178-187) +- `internal/cmd/pr/view.go` -- Update to use `api.PullRequest`, `api.User`, `api.Participant`, replace `getUserDisplayName(PRUser)` with `cmdutil.GetUserDisplayName(&api.User)`, fix `time.Parse` of `CreatedOn` (api type uses `time.Time` not `string`) + +--- + +## Phase 3: Dockerfile + +### Task 11: Create multi-stage Dockerfile + +**Files:** +- Create: `Dockerfile` +- Create: `.dockerignore` + +```dockerfile +# Build stage +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +ARG BUILD_DATE + +RUN CGO_ENABLED=0 go build \ + -ldflags "-s -w -X github.com/rbansal42/bitbucket-cli/internal/cmd.Version=${VERSION} -X github.com/rbansal42/bitbucket-cli/internal/cmd.BuildDate=${BUILD_DATE}" \ + -o /bin/bb ./cmd/bb + +# Runtime stage +FROM alpine:3.21 + +RUN apk add --no-cache git ca-certificates + +COPY --from=builder /bin/bb /usr/local/bin/bb + +ENTRYPOINT ["bb"] +``` + +``` +# .dockerignore +.git +.worktrees +bin/ +cover.out +*.md +!README.md +docs/ +.github/ +``` diff --git a/go.mod b/go.mod index 9c7bfe4..1665ae5 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/rbansal42/bb +module github.com/rbansal42/bitbucket-cli go 1.25.7 diff --git a/internal/api/client.go b/internal/api/client.go index f30ed61..77753aa 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -28,6 +28,8 @@ type Client struct { baseURL string httpClient *http.Client token string + username string // For Basic Auth with API tokens + apiToken string // For Basic Auth with API tokens } // ClientOption is a functional option for configuring the client @@ -49,13 +51,22 @@ func NewClient(opts ...ClientOption) *Client { return c } -// WithToken sets the authentication token +// WithToken sets the authentication token (Bearer token for OAuth/Access Tokens) func WithToken(token string) ClientOption { return func(c *Client) { c.token = token } } +// WithBasicAuth sets username and API token for Basic Auth +// Used with Atlassian API tokens (email:api_token) +func WithBasicAuth(username, apiToken string) ClientOption { + return func(c *Client) { + c.username = username + c.apiToken = apiToken + } +} + // WithBaseURL sets a custom base URL func WithBaseURL(baseURL string) ClientOption { return func(c *Client) { @@ -144,7 +155,12 @@ func (c *Client) Do(ctx context.Context, req *Request) (*Response, error) { httpReq.Header.Set("Content-Type", "application/json") } - if c.token != "" { + // Set authentication + if c.username != "" && c.apiToken != "" { + // Basic Auth for Atlassian API tokens + httpReq.SetBasicAuth(c.username, c.apiToken) + } else if c.token != "" { + // Bearer token for OAuth or Access Tokens httpReq.Header.Set("Authorization", "Bearer "+c.token) } @@ -257,6 +273,7 @@ type User struct { UUID string `json:"uuid"` Username string `json:"username"` DisplayName string `json:"display_name"` + Nickname string `json:"nickname"` AccountID string `json:"account_id"` Links struct { Avatar struct { diff --git a/internal/cmd/api/api.go b/internal/cmd/api/api.go index b1aa4c9..4f057e3 100644 --- a/internal/cmd/api/api.go +++ b/internal/cmd/api/api.go @@ -12,8 +12,8 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdAPI creates the api command diff --git a/internal/cmd/auth/auth.go b/internal/cmd/auth/auth.go index 55fbc5b..8d01078 100644 --- a/internal/cmd/auth/auth.go +++ b/internal/cmd/auth/auth.go @@ -3,7 +3,7 @@ package auth import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdAuth creates the auth command diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index af0a009..5ec5df6 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -16,10 +16,10 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/browser" - "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/browser" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) const ( @@ -52,15 +52,16 @@ func NewCmdLogin(streams *iostreams.IOStreams) *cobra.Command { Short: "Authenticate with Bitbucket", Long: `Authenticate with Bitbucket to enable API access. -The default authentication mode is interactive and uses OAuth 2.0. -This will open a browser window for you to authorize bb. +This command will guide you through authentication setup interactively. +You can choose between: + - API Token: Simple setup, good for CI/CD and automation + - OAuth: More secure, supports token refresh -Alternatively, you can use --with-token to read a token from stdin. -This is useful for automation or when using workspace/repository access tokens.`, - Example: ` # Interactive OAuth login +Alternatively, use --with-token to read a token directly from stdin.`, + Example: ` # Interactive login (recommended) $ bb auth login - # Login with an access token + # Login with a token from stdin (for CI/CD) $ echo "your_token" | bb auth login --with-token # Login with a token from a file @@ -78,14 +79,227 @@ This is useful for automation or when using workspace/repository access tokens.` } func runLogin(opts *loginOptions) error { + // If --with-token flag is set, read token from stdin if opts.withToken { - return loginWithToken(opts) + return loginWithTokenFromStdin(opts) } - return loginWithOAuth(opts) + + // Interactive flow + return interactiveLogin(opts) +} + +func interactiveLogin(opts *loginOptions) error { + reader := bufio.NewReader(os.Stdin) + + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintln(opts.streams.Out, "Welcome to bb CLI! Let's get you authenticated with Bitbucket.") + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintln(opts.streams.Out, "How would you like to authenticate?") + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintln(opts.streams.Out, " [1] API Token (simple, good for CI/CD)") + fmt.Fprintln(opts.streams.Out, " [2] OAuth (more secure, supports token refresh)") + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprint(opts.streams.Out, "Enter choice [1/2]: ") + + choice, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + choice = strings.TrimSpace(choice) + + var loginErr error + switch choice { + case "1": + loginErr = interactiveAPITokenLogin(opts, reader) + case "2": + loginErr = interactiveOAuthLogin(opts, reader) + default: + return fmt.Errorf("invalid choice: %s (enter 1 or 2)", choice) + } + + if loginErr != nil { + return loginErr + } + + // After successful login, ask about default workspace + return promptForDefaultWorkspace(opts, reader) +} + +func interactiveAPITokenLogin(opts *loginOptions, reader *bufio.Reader) error { + const apiTokenURL = "https://id.atlassian.com/manage-profile/security/api-tokens" + + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintln(opts.streams.Out, "=== API Token Authentication ===") + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintln(opts.streams.Out, "To create an API token:") + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintf(opts.streams.Out, " 1. Opening: %s\n", apiTokenURL) + fmt.Fprintln(opts.streams.Out, " 2. Click 'Create API token'") + fmt.Fprintln(opts.streams.Out, " 3. Enter a label (e.g., 'bb-cli')") + fmt.Fprintln(opts.streams.Out, " 4. Click 'Create' and copy the token") + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprint(opts.streams.Out, "Press Enter to open browser (or 'n' to skip): ") + + skipBrowser, _ := reader.ReadString('\n') + skipBrowser = strings.TrimSpace(strings.ToLower(skipBrowser)) + + if skipBrowser != "n" && skipBrowser != "no" { + if err := browser.Open(apiTokenURL); err != nil { + opts.streams.Warning("Failed to open browser: %v", err) + fmt.Fprintf(opts.streams.Out, "Please open manually: %s\n", apiTokenURL) + } + } + + // Get email for Basic Auth + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprint(opts.streams.Out, "Enter your Atlassian account email: ") + + email, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read email: %w", err) + } + email = strings.TrimSpace(email) + + if email == "" { + return fmt.Errorf("email cannot be empty") + } + + // Token entry loop with retry on invalid token + for { + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprint(opts.streams.Out, "Paste your API token: ") + + token, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read token: %w", err) + } + token = strings.TrimSpace(token) + + if token == "" { + return fmt.Errorf("token cannot be empty") + } + + // Validate and save the token (using Basic Auth) + err = validateAndSaveAPIToken(opts, email, token) + if err == nil { + return nil // Success! + } + + // Token validation failed - offer to retry + fmt.Fprintln(opts.streams.Out, "") + opts.streams.Error("Token validation failed: %v", err) + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintln(opts.streams.Out, "Please ensure:") + fmt.Fprintln(opts.streams.Out, " - Your email is correct") + fmt.Fprintln(opts.streams.Out, " - The API token was copied correctly (no extra spaces)") + fmt.Fprintln(opts.streams.Out, " - The API token has not been revoked") + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprint(opts.streams.Out, "Try again? [Y/n]: ") + + retry, _ := reader.ReadString('\n') + retry = strings.TrimSpace(strings.ToLower(retry)) + + if retry == "n" || retry == "no" { + return fmt.Errorf("authentication cancelled") + } + // Loop continues for retry + } +} + +func interactiveOAuthLogin(opts *loginOptions, reader *bufio.Reader) error { + // Check if OAuth credentials are already configured + clientID := os.Getenv("BB_OAUTH_CLIENT_ID") + clientSecret := os.Getenv("BB_OAUTH_CLIENT_SECRET") + + if clientID != "" && clientSecret != "" { + // Credentials are set, proceed with OAuth flow + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintln(opts.streams.Out, "OAuth credentials found. Starting authentication...") + return performOAuthFlow(opts, clientID, clientSecret) + } + + // Need to set up OAuth consumer first + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintln(opts.streams.Out, "=== OAuth Authentication Setup ===") + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintln(opts.streams.Out, "OAuth requires a one-time setup of an OAuth consumer in Bitbucket.") + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprint(opts.streams.Out, "Enter your workspace name (e.g., 'myteam'): ") + + workspace, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + workspace = strings.TrimSpace(workspace) + + if workspace == "" { + return fmt.Errorf("workspace name is required") + } + + // Construct the URL for creating OAuth consumers + oauthURL := fmt.Sprintf("https://bitbucket.org/%s/workspace/settings/oauth-consumers", workspace) + + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintln(opts.streams.Out, "To create an OAuth consumer:") + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintf(opts.streams.Out, " 1. Opening: %s\n", oauthURL) + fmt.Fprintln(opts.streams.Out, " 2. Click 'Add consumer'") + fmt.Fprintln(opts.streams.Out, " 3. Fill in:") + fmt.Fprintln(opts.streams.Out, " - Name: bb CLI") + fmt.Fprintln(opts.streams.Out, " - Callback URL: http://localhost:8372/callback") + fmt.Fprintln(opts.streams.Out, " - [x] This is a private consumer") + fmt.Fprintln(opts.streams.Out, " 4. Select permissions (Account, Repositories, Pull requests, etc.)") + fmt.Fprintln(opts.streams.Out, " 5. Click 'Save'") + fmt.Fprintln(opts.streams.Out, " 6. Copy the 'Key' and 'Secret'") + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprint(opts.streams.Out, "Press Enter to open browser (or 'n' to skip): ") + + skipBrowser, _ := reader.ReadString('\n') + skipBrowser = strings.TrimSpace(strings.ToLower(skipBrowser)) + + if skipBrowser != "n" && skipBrowser != "no" { + if err := browser.Open(oauthURL); err != nil { + opts.streams.Warning("Failed to open browser: %v", err) + fmt.Fprintf(opts.streams.Out, "Please open manually: %s\n", oauthURL) + } + } + + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprint(opts.streams.Out, "Paste your OAuth Key (Client ID): ") + clientID, err = reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read client ID: %w", err) + } + clientID = strings.TrimSpace(clientID) + + if clientID == "" { + return fmt.Errorf("client ID cannot be empty") + } + + fmt.Fprint(opts.streams.Out, "Paste your OAuth Secret (Client Secret): ") + clientSecret, err = reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read client secret: %w", err) + } + clientSecret = strings.TrimSpace(clientSecret) + + if clientSecret == "" { + return fmt.Errorf("client secret cannot be empty") + } + + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintln(opts.streams.Out, "To avoid entering these again, add to your shell profile (~/.zshrc or ~/.bashrc):") + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintf(opts.streams.Out, " export BB_OAUTH_CLIENT_ID=\"%s\"\n", clientID) + fmt.Fprintf(opts.streams.Out, " export BB_OAUTH_CLIENT_SECRET=\"%s\"\n", clientSecret) + fmt.Fprintln(opts.streams.Out, "") + + // Proceed with OAuth flow + return performOAuthFlow(opts, clientID, clientSecret) } -func loginWithToken(opts *loginOptions) error { - opts.streams.Info("Paste your access token:") +func loginWithTokenFromStdin(opts *loginOptions) error { + opts.streams.Info("Reading token from stdin...") scanner := bufio.NewScanner(os.Stdin) if !scanner.Scan() { @@ -97,7 +311,13 @@ func loginWithToken(opts *loginOptions) error { return fmt.Errorf("empty token provided") } - // Validate token by making an API request + return validateAndSaveToken(opts, token) +} + +func validateAndSaveToken(opts *loginOptions, token string) error { + opts.streams.Info("Validating token...") + + // Validate token by making an API request (Bearer token) client := api.NewClient(api.WithToken(token)) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -124,32 +344,166 @@ func loginWithToken(opts *loginOptions) error { return fmt.Errorf("failed to save hosts config: %w", err) } - opts.streams.Success("Logged in as %s", user.Username) + opts.streams.Success("Logged in as: %s (%s)", user.DisplayName, user.Username) return nil } -func loginWithOAuth(opts *loginOptions) error { - // Check if OAuth client credentials are available - clientID := os.Getenv("BB_OAUTH_CLIENT_ID") - clientSecret := os.Getenv("BB_OAUTH_CLIENT_SECRET") +func validateAndSaveAPIToken(opts *loginOptions, email, apiToken string) error { + opts.streams.Info("Validating credentials...") + + // Validate using Basic Auth (email:api_token) + client := api.NewClient(api.WithBasicAuth(email, apiToken)) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + user, err := client.GetCurrentUser(ctx) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + // Store credentials - we store as "email:token" format for Basic Auth + credentials := email + ":" + apiToken + if err := config.SetToken(opts.hostname, user.Username, "basic:"+credentials); err != nil { + return fmt.Errorf("failed to store credentials: %w", err) + } + + // Update hosts config + hosts, err := config.LoadHostsConfig() + if err != nil { + return fmt.Errorf("failed to load hosts config: %w", err) + } + + hosts.SetActiveUser(opts.hostname, user.Username) + + if err := config.SaveHostsConfig(hosts); err != nil { + return fmt.Errorf("failed to save hosts config: %w", err) + } + + opts.streams.Success("Logged in as: %s (%s)", user.DisplayName, email) + return nil +} + +func promptForDefaultWorkspace(opts *loginOptions, reader *bufio.Reader) error { + // Check current default workspace + currentDefault, _ := config.GetDefaultWorkspace() + if currentDefault != "" { + fmt.Fprintln(opts.streams.Out, "") + opts.streams.Info("Current default workspace: %s", currentDefault) + return nil + } + + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprint(opts.streams.Out, "Would you like to set a default workspace? [y/N]: ") + + answer, err := reader.ReadString('\n') + if err != nil { + return nil // Don't fail login if this fails + } + answer = strings.TrimSpace(strings.ToLower(answer)) + + if answer != "y" && answer != "yes" { + fmt.Fprintln(opts.streams.Out, "You can set a default workspace later with: bb workspace set-default <workspace>") + return nil + } + + // List available workspaces + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintln(opts.streams.Out, "Fetching your workspaces...") + + apiClient, err := getAuthenticatedClient(opts.hostname) + if err != nil { + opts.streams.Warning("Could not fetch workspaces: %v", err) + fmt.Fprintln(opts.streams.Out, "You can set a default workspace later with: bb workspace set-default <workspace>") + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() - if clientID == "" || clientSecret == "" { - return fmt.Errorf(`OAuth client credentials not configured. + result, err := apiClient.ListWorkspaces(ctx, nil) + if err != nil { + opts.streams.Warning("Could not fetch workspaces: %v", err) + fmt.Fprintln(opts.streams.Out, "You can set a default workspace later with: bb workspace set-default <workspace>") + return nil + } + + workspaces := result.Values + if len(workspaces) == 0 { + opts.streams.Info("No workspaces found") + return nil + } + + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprintln(opts.streams.Out, "Available workspaces:") + for i, membership := range workspaces { + fmt.Fprintf(opts.streams.Out, " [%d] %s (%s)\n", i+1, membership.Workspace.Name, membership.Workspace.Slug) + } + fmt.Fprintln(opts.streams.Out, "") + fmt.Fprint(opts.streams.Out, "Enter number to select (or press Enter to skip): ") + + selection, err := reader.ReadString('\n') + if err != nil { + return nil + } + selection = strings.TrimSpace(selection) + + if selection == "" { + fmt.Fprintln(opts.streams.Out, "You can set a default workspace later with: bb workspace set-default <workspace>") + return nil + } + + var idx int + if _, err := fmt.Sscanf(selection, "%d", &idx); err != nil || idx < 1 || idx > len(workspaces) { + opts.streams.Warning("Invalid selection") + return nil + } + + selectedWorkspace := workspaces[idx-1].Workspace.Slug + if err := config.SetDefaultWorkspace(selectedWorkspace); err != nil { + opts.streams.Warning("Failed to set default workspace: %v", err) + return nil + } + + opts.streams.Success("Default workspace set to: %s", selectedWorkspace) + return nil +} -To use OAuth authentication, you need to create an OAuth consumer in Bitbucket: -1. Go to https://bitbucket.org/account/settings/app-passwords/ (for your personal account) - or your workspace settings > OAuth consumers -2. Create a new OAuth consumer with: - - Callback URL: http://localhost:8372/callback - - Permissions: Account (Read), Repositories (Read/Write), Pull requests (Read/Write), etc. -3. Set the environment variables: - export BB_OAUTH_CLIENT_ID="your_client_id" - export BB_OAUTH_CLIENT_SECRET="your_client_secret" +func getAuthenticatedClient(hostname string) (*api.Client, error) { + hosts, err := config.LoadHostsConfig() + if err != nil { + return nil, err + } -Alternatively, use --with-token to authenticate with an access token: - bb auth login --with-token`) + user := hosts.GetActiveUser(hostname) + if user == "" { + return nil, fmt.Errorf("not logged in") + } + + tokenData, _, err := config.GetTokenFromEnvOrKeyring(hostname, user) + if err != nil { + return nil, err } + // Check if this is Basic Auth credentials + if strings.HasPrefix(tokenData, "basic:") { + credentials := strings.TrimPrefix(tokenData, "basic:") + parts := strings.SplitN(credentials, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid credentials format") + } + return api.NewClient(api.WithBasicAuth(parts[0], parts[1])), nil + } + + // Try to parse as JSON (OAuth token) + var tokenResp oauthTokenResponse + if err := json.Unmarshal([]byte(tokenData), &tokenResp); err == nil && tokenResp.AccessToken != "" { + return api.NewClient(api.WithToken(tokenResp.AccessToken)), nil + } + + return api.NewClient(api.WithToken(tokenData)), nil +} + +func performOAuthFlow(opts *loginOptions, clientID, clientSecret string) error { // Generate state for CSRF protection state, err := generateState() if err != nil { @@ -177,8 +531,6 @@ Alternatively, use --with-token to authenticate with an access token: q.Set("response_type", "code") q.Set("redirect_uri", callbackURL) q.Set("state", state) - // Note: Bitbucket doesn't use scope parameter in the same way as GitHub - // Permissions are set in the OAuth consumer configuration authURL.RawQuery = q.Encode() // Channel to receive authorization code @@ -283,8 +635,7 @@ Alternatively, use --with-token to authenticate with an access token: return fmt.Errorf("failed to get user info: %w", err) } - // Store tokens in keyring - // We store both access and refresh tokens as JSON + // Store tokens in keyring (as JSON with refresh token) tokenData, err := json.Marshal(tokenResp) if err != nil { return fmt.Errorf("failed to marshal token: %w", err) @@ -306,7 +657,7 @@ Alternatively, use --with-token to authenticate with an access token: return fmt.Errorf("failed to save hosts config: %w", err) } - opts.streams.Success("Logged in as %s", user.Username) + opts.streams.Success("Logged in as: %s (%s)", user.DisplayName, user.Username) return nil } diff --git a/internal/cmd/auth/logout.go b/internal/cmd/auth/logout.go index c6070a6..2bd480e 100644 --- a/internal/cmd/auth/logout.go +++ b/internal/cmd/auth/logout.go @@ -5,8 +5,8 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type logoutOptions struct { diff --git a/internal/cmd/auth/status.go b/internal/cmd/auth/status.go index f1649b5..88dc77a 100644 --- a/internal/cmd/auth/status.go +++ b/internal/cmd/auth/status.go @@ -9,9 +9,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type statusOptions struct { @@ -66,17 +66,33 @@ func runStatus(opts *statusOptions) error { return nil } - // Try to parse as JSON (OAuth token) or use as plain token - var token string - var tokenResp oauthTokenResponse - if err := json.Unmarshal([]byte(tokenData), &tokenResp); err == nil && tokenResp.AccessToken != "" { - token = tokenResp.AccessToken + // Create API client based on token type + var client *api.Client + var displayToken string + + if strings.HasPrefix(tokenData, "basic:") { + // Basic Auth credentials (email:api_token) + credentials := strings.TrimPrefix(tokenData, "basic:") + parts := strings.SplitN(credentials, ":", 2) + if len(parts) != 2 { + opts.streams.Info("%s", opts.hostname) + opts.streams.Error("Invalid stored credentials format for %s", user) + return nil + } + client = api.NewClient(api.WithBasicAuth(parts[0], parts[1])) + displayToken = parts[1] // Show API token portion } else { - token = tokenData + // Try to parse as JSON (OAuth token) or use as plain token + var tokenResp oauthTokenResponse + if err := json.Unmarshal([]byte(tokenData), &tokenResp); err == nil && tokenResp.AccessToken != "" { + displayToken = tokenResp.AccessToken + } else { + displayToken = tokenData + } + client = api.NewClient(api.WithToken(displayToken)) } // Validate token by making an API request - client := api.NewClient(api.WithToken(token)) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -95,7 +111,7 @@ func runStatus(opts *statusOptions) error { opts.streams.Info(" - Git operations protocol: %s", hosts.GetGitProtocol(opts.hostname)) // Mask token for display - maskedToken := maskToken(token) + maskedToken := maskToken(displayToken) opts.streams.Info(" - Token: %s", maskedToken) return nil diff --git a/internal/cmd/auth/token.go b/internal/cmd/auth/token.go index 6f7e332..c2dcbda 100644 --- a/internal/cmd/auth/token.go +++ b/internal/cmd/auth/token.go @@ -6,8 +6,8 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type tokenOptions struct { diff --git a/internal/cmd/branch/branch.go b/internal/cmd/branch/branch.go index 13a3bb8..df7114f 100644 --- a/internal/cmd/branch/branch.go +++ b/internal/cmd/branch/branch.go @@ -3,7 +3,7 @@ package branch import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdBranch creates the branch command and its subcommands diff --git a/internal/cmd/branch/create.go b/internal/cmd/branch/create.go index 4645fcb..4731380 100644 --- a/internal/cmd/branch/create.go +++ b/internal/cmd/branch/create.go @@ -8,8 +8,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // CreateOptions holds the options for the create command @@ -63,13 +64,13 @@ By default, this command detects the repository from your git remote.`, func runCreate(ctx context.Context, opts *CreateOptions) error { // Parse repository - workspace, repoSlug, err := parseRepository(opts.Repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.Repo) if err != nil { return err } // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/branch/delete.go b/internal/cmd/branch/delete.go index f2ebbe6..3be2b1b 100644 --- a/internal/cmd/branch/delete.go +++ b/internal/cmd/branch/delete.go @@ -9,7 +9,8 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // DeleteOptions holds the options for the delete command @@ -58,7 +59,7 @@ By default, this command detects the repository from your git remote.`, func runDelete(ctx context.Context, opts *DeleteOptions) error { // Parse repository - workspace, repoSlug, err := parseRepository(opts.Repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.Repo) if err != nil { return err } @@ -85,7 +86,7 @@ func runDelete(ctx context.Context, opts *DeleteOptions) error { } // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/branch/list.go b/internal/cmd/branch/list.go index eca2cde..a7c04b1 100644 --- a/internal/cmd/branch/list.go +++ b/internal/cmd/branch/list.go @@ -2,16 +2,15 @@ package branch import ( "context" - "encoding/json" "fmt" - "strings" "text/tabwriter" "time" "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // ListOptions holds the options for the list command @@ -61,13 +60,13 @@ Use the --repo flag to specify a different repository.`, func runList(ctx context.Context, opts *ListOptions) error { // Parse repository - workspace, repoSlug, err := parseRepository(opts.Repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.Repo) if err != nil { return err } // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -114,13 +113,7 @@ func outputListJSON(streams *iostreams.IOStreams, branches []api.BranchFull) err output[i] = item } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputTable(streams *iostreams.IOStreams, branches []api.BranchFull) error { @@ -128,11 +121,7 @@ func outputTable(streams *iostreams.IOStreams, branches []api.BranchFull) error // Print header header := "NAME\tCOMMIT\tMESSAGE" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, branch := range branches { @@ -148,7 +137,7 @@ func outputTable(streams *iostreams.IOStreams, branches []api.BranchFull) error commit = branch.Target.Hash } // Truncate message to 50 chars and replace newlines - message = truncateMessage(branch.Target.Message, 50) + message = cmdutil.TruncateString(branch.Target.Message, 50) } fmt.Fprintf(w, "%s\t%s\t%s\n", name, commit, message) @@ -156,23 +145,3 @@ func outputTable(streams *iostreams.IOStreams, branches []api.BranchFull) error return w.Flush() } - -// truncateMessage truncates a message to maxLen characters and replaces newlines -func truncateMessage(s string, maxLen int) string { - // Replace newlines with spaces - s = strings.ReplaceAll(s, "\n", " ") - s = strings.ReplaceAll(s, "\r", " ") - // Collapse multiple spaces - for strings.Contains(s, " ") { - s = strings.ReplaceAll(s, " ", " ") - } - s = strings.TrimSpace(s) - - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} diff --git a/internal/cmd/branch/shared.go b/internal/cmd/branch/shared.go index f23869e..858f8e4 100644 --- a/internal/cmd/branch/shared.go +++ b/internal/cmd/branch/shared.go @@ -1,63 +1,5 @@ package branch -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/git" -) - -// getAPIClient creates an authenticated API client -func getAPIClient() (*api.Client, error) { - hosts, err := config.LoadHostsConfig() - if err != nil { - return nil, fmt.Errorf("failed to load hosts config: %w", err) - } - - user := hosts.GetActiveUser(config.DefaultHost) - if user == "" { - return nil, fmt.Errorf("not logged in. Run 'bb auth login' to authenticate") - } - - tokenData, _, err := config.GetTokenFromEnvOrKeyring(config.DefaultHost, user) - if err != nil { - return nil, fmt.Errorf("failed to get token: %w", err) - } - - // Try to parse as JSON (OAuth token) or use as plain token - var tokenResp struct { - AccessToken string `json:"access_token"` - } - token := tokenData - if err := json.Unmarshal([]byte(tokenData), &tokenResp); err == nil && tokenResp.AccessToken != "" { - token = tokenResp.AccessToken - } - - return api.NewClient(api.WithToken(token)), nil -} - -// parseRepository parses a repository string or detects from git remote -func parseRepository(repoFlag string) (workspace, repoSlug string, err error) { - if repoFlag != "" { - parts := strings.SplitN(repoFlag, "/", 2) - if len(parts) != 2 { - return "", "", fmt.Errorf("invalid repository format: %s (expected workspace/repo)", repoFlag) - } - // Validate both parts are non-empty - if parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("invalid repository format: %s (workspace and repo cannot be empty)", repoFlag) - } - return parts[0], parts[1], nil - } - - // Detect from git - remote, err := git.GetDefaultRemote() - if err != nil { - return "", "", fmt.Errorf("could not detect repository: %w\nUse --repo WORKSPACE/REPO to specify", err) - } - - return remote.Workspace, remote.RepoSlug, nil -} +// This file previously contained shared helper functions. +// These have been moved to the cmdutil package for reuse across commands. +// Use cmdutil.GetAPIClient() and cmdutil.ParseRepository() instead. diff --git a/internal/cmd/browse/browse.go b/internal/cmd/browse/browse.go index e1dcecf..2255589 100644 --- a/internal/cmd/browse/browse.go +++ b/internal/cmd/browse/browse.go @@ -9,9 +9,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/git" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/git" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdBrowse creates the browse command diff --git a/internal/cmd/completion/bash.go b/internal/cmd/completion/bash.go index 68d921e..6c6c0a8 100644 --- a/internal/cmd/completion/bash.go +++ b/internal/cmd/completion/bash.go @@ -3,7 +3,7 @@ package completion import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdBash creates the bash completion command diff --git a/internal/cmd/completion/completion.go b/internal/cmd/completion/completion.go index 7911e1e..b5f6190 100644 --- a/internal/cmd/completion/completion.go +++ b/internal/cmd/completion/completion.go @@ -3,7 +3,7 @@ package completion import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdCompletion creates the completion command and its subcommands diff --git a/internal/cmd/completion/fish.go b/internal/cmd/completion/fish.go index 9b46f2c..c365ff3 100644 --- a/internal/cmd/completion/fish.go +++ b/internal/cmd/completion/fish.go @@ -3,7 +3,7 @@ package completion import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdFish creates the fish completion command diff --git a/internal/cmd/completion/powershell.go b/internal/cmd/completion/powershell.go index ac54218..71f5e0a 100644 --- a/internal/cmd/completion/powershell.go +++ b/internal/cmd/completion/powershell.go @@ -3,7 +3,7 @@ package completion import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdPowerShell creates the powershell completion command diff --git a/internal/cmd/completion/zsh.go b/internal/cmd/completion/zsh.go index db7b635..53d3e74 100644 --- a/internal/cmd/completion/zsh.go +++ b/internal/cmd/completion/zsh.go @@ -3,7 +3,7 @@ package completion import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdZsh creates the zsh completion command diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index 6bdd3e4..71e4598 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -3,7 +3,7 @@ package config import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdConfig creates the config command diff --git a/internal/cmd/config/get.go b/internal/cmd/config/get.go index b9ddbaa..2b82b59 100644 --- a/internal/cmd/config/get.go +++ b/internal/cmd/config/get.go @@ -7,8 +7,8 @@ import ( "github.com/spf13/cobra" - coreconfig "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/iostreams" + coreconfig "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdConfigGet creates the config get command diff --git a/internal/cmd/config/list.go b/internal/cmd/config/list.go index f12411e..56824b5 100644 --- a/internal/cmd/config/list.go +++ b/internal/cmd/config/list.go @@ -5,8 +5,8 @@ import ( "github.com/spf13/cobra" - coreconfig "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/iostreams" + coreconfig "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdConfigList creates the config list command diff --git a/internal/cmd/config/set.go b/internal/cmd/config/set.go index 0157b0e..d055ab1 100644 --- a/internal/cmd/config/set.go +++ b/internal/cmd/config/set.go @@ -7,8 +7,8 @@ import ( "github.com/spf13/cobra" - coreconfig "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/iostreams" + coreconfig "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdConfigSet creates the config set command diff --git a/internal/cmd/issue/close.go b/internal/cmd/issue/close.go index ccfae5f..20f1fb9 100644 --- a/internal/cmd/issue/close.go +++ b/internal/cmd/issue/close.go @@ -7,8 +7,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type closeOptions struct { @@ -55,12 +56,12 @@ func runClose(opts *closeOptions, args []string) error { return err } - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/issue/comment.go b/internal/cmd/issue/comment.go index 08e80a0..cad6e53 100644 --- a/internal/cmd/issue/comment.go +++ b/internal/cmd/issue/comment.go @@ -6,7 +6,8 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type commentOptions struct { @@ -48,7 +49,7 @@ func runComment(opts *commentOptions, args []string) error { return err } - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } @@ -58,7 +59,7 @@ func runComment(opts *commentOptions, args []string) error { return fmt.Errorf("comment body required, use --body flag") } - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/issue/create.go b/internal/cmd/issue/create.go index c8f1380..b6794b2 100644 --- a/internal/cmd/issue/create.go +++ b/internal/cmd/issue/create.go @@ -7,8 +7,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type createOptions struct { @@ -67,13 +68,13 @@ to enter a title interactively.`, func runCreate(opts *createOptions) error { // Resolve repository - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/issue/delete.go b/internal/cmd/issue/delete.go index 2363d22..c465a84 100644 --- a/internal/cmd/issue/delete.go +++ b/internal/cmd/issue/delete.go @@ -7,7 +7,8 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type deleteOptions struct { @@ -57,7 +58,7 @@ func runDelete(opts *deleteOptions, args []string) error { return err } - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } @@ -76,7 +77,7 @@ func runDelete(opts *deleteOptions, args []string) error { } } - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/issue/edit.go b/internal/cmd/issue/edit.go index cc11184..737648e 100644 --- a/internal/cmd/issue/edit.go +++ b/internal/cmd/issue/edit.go @@ -7,8 +7,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type editOptions struct { @@ -96,13 +97,13 @@ func runEdit(opts *editOptions) error { } // Resolve repository - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/issue/issue.go b/internal/cmd/issue/issue.go index a7ebfa6..b942416 100644 --- a/internal/cmd/issue/issue.go +++ b/internal/cmd/issue/issue.go @@ -3,7 +3,7 @@ package issue import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdIssue creates the issue command and its subcommands diff --git a/internal/cmd/issue/list.go b/internal/cmd/issue/list.go index ea481d4..1e80c86 100644 --- a/internal/cmd/issue/list.go +++ b/internal/cmd/issue/list.go @@ -2,15 +2,14 @@ package issue import ( "context" - "encoding/json" "fmt" "text/tabwriter" - "time" "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // ListOptions holds the options for the list command @@ -80,13 +79,13 @@ priority, or assignee.`, func runList(ctx context.Context, opts *ListOptions) error { // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } // Parse repository - workspace, repoSlug, err := parseRepository(opts.Repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.Repo) if err != nil { return err } @@ -129,8 +128,8 @@ func outputListJSON(streams *iostreams.IOStreams, issues []api.Issue) error { "state": issue.State, "kind": issue.Kind, "priority": issue.Priority, - "reporter": getUserDisplayName(issue.Reporter), - "assignee": getUserDisplayName(issue.Assignee), + "reporter": cmdutil.GetUserDisplayName(issue.Reporter), + "assignee": cmdutil.GetUserDisplayName(issue.Assignee), "votes": issue.Votes, "created_on": issue.CreatedOn, "updated_on": issue.UpdatedOn, @@ -140,13 +139,7 @@ func outputListJSON(streams *iostreams.IOStreams, issues []api.Issue) error { } } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputIssueTable(streams *iostreams.IOStreams, issues []api.Issue) error { @@ -154,21 +147,17 @@ func outputIssueTable(streams *iostreams.IOStreams, issues []api.Issue) error { // Print header header := "#\tTITLE\tSTATE\tKIND\tPRIORITY\tASSIGNEE\tUPDATED" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, issue := range issues { id := fmt.Sprintf("%d", issue.ID) - title := truncateString(issue.Title, 40) + title := cmdutil.TruncateString(issue.Title, 40) state := formatIssueState(streams, issue.State) kind := formatIssueKind(streams, issue.Kind) priority := formatIssuePriority(streams, issue.Priority) - assignee := truncateString(getUserDisplayName(issue.Assignee), 15) - updated := formatUpdated(issue.UpdatedOn) + assignee := cmdutil.TruncateString(cmdutil.GetUserDisplayName(issue.Assignee), 15) + updated := cmdutil.TimeAgo(issue.UpdatedOn) fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", id, title, state, kind, priority, assignee, updated) @@ -176,10 +165,3 @@ func outputIssueTable(streams *iostreams.IOStreams, issues []api.Issue) error { return w.Flush() } - -func formatUpdated(t time.Time) string { - if t.IsZero() { - return "-" - } - return timeAgo(t) -} diff --git a/internal/cmd/issue/reopen.go b/internal/cmd/issue/reopen.go index 556dc6a..1f237b8 100644 --- a/internal/cmd/issue/reopen.go +++ b/internal/cmd/issue/reopen.go @@ -7,8 +7,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type reopenOptions struct { @@ -51,12 +52,12 @@ func runReopen(opts *reopenOptions, args []string) error { return err } - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/issue/shared.go b/internal/cmd/issue/shared.go index ddd4496..0f376c4 100644 --- a/internal/cmd/issue/shared.go +++ b/internal/cmd/issue/shared.go @@ -9,66 +9,11 @@ import ( "os" "strconv" "strings" - "time" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/git" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) -// getAPIClient creates an authenticated API client -func getAPIClient() (*api.Client, error) { - hosts, err := config.LoadHostsConfig() - if err != nil { - return nil, fmt.Errorf("failed to load hosts config: %w", err) - } - - user := hosts.GetActiveUser(config.DefaultHost) - if user == "" { - return nil, fmt.Errorf("not logged in. Run 'bb auth login' to authenticate") - } - - tokenData, _, err := config.GetTokenFromEnvOrKeyring(config.DefaultHost, user) - if err != nil { - return nil, fmt.Errorf("failed to get token: %w", err) - } - - // Try to parse as JSON (OAuth token) or use as plain token - var tokenResp struct { - AccessToken string `json:"access_token"` - } - token := tokenData - if err := json.Unmarshal([]byte(tokenData), &tokenResp); err == nil && tokenResp.AccessToken != "" { - token = tokenResp.AccessToken - } - - return api.NewClient(api.WithToken(token)), nil -} - -// parseRepository parses a repository string or detects from git remote -func parseRepository(repoFlag string) (workspace, repoSlug string, err error) { - if repoFlag != "" { - parts := strings.SplitN(repoFlag, "/", 2) - if len(parts) != 2 { - return "", "", fmt.Errorf("invalid repository format: %s (expected workspace/repo)", repoFlag) - } - // Validate both parts are non-empty - if parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("invalid repository format: %s (workspace and repo cannot be empty)", repoFlag) - } - return parts[0], parts[1], nil - } - - // Detect from git - remote, err := git.GetDefaultRemote() - if err != nil { - return "", "", fmt.Errorf("could not detect repository: %w\nUse --repo WORKSPACE/REPO to specify", err) - } - - return remote.Workspace, remote.RepoSlug, nil -} - // parseIssueID parses an issue ID from args or returns an error func parseIssueID(args []string) (int, error) { if len(args) == 0 { @@ -150,71 +95,6 @@ func formatIssueKind(streams *iostreams.IOStreams, kind string) string { } } -// timeAgo returns a human-readable relative time string -func timeAgo(t time.Time) string { - duration := time.Since(t) - - switch { - case duration < time.Minute: - return "just now" - case duration < time.Hour: - mins := int(duration.Minutes()) - if mins == 1 { - return "1 minute ago" - } - return fmt.Sprintf("%d minutes ago", mins) - case duration < 24*time.Hour: - hours := int(duration.Hours()) - if hours == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", hours) - case duration < 30*24*time.Hour: - days := int(duration.Hours() / 24) - if days == 1 { - return "1 day ago" - } - return fmt.Sprintf("%d days ago", days) - case duration < 365*24*time.Hour: - months := int(duration.Hours() / 24 / 30) - if months == 1 { - return "1 month ago" - } - return fmt.Sprintf("%d months ago", months) - default: - years := int(duration.Hours() / 24 / 365) - if years == 1 { - return "1 year ago" - } - return fmt.Sprintf("%d years ago", years) - } -} - -// truncateString truncates a string to maxLen characters with ellipsis -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} - -// getUserDisplayName returns the best available display name for a user -func getUserDisplayName(user *api.User) string { - if user == nil { - return "-" - } - if user.DisplayName != "" { - return user.DisplayName - } - if user.Username != "" { - return user.Username - } - return "unknown" -} - // confirmPrompt prompts the user with a yes/no question and returns true if they confirm func confirmPrompt(reader io.Reader) bool { scanner := bufio.NewScanner(reader) diff --git a/internal/cmd/issue/view.go b/internal/cmd/issue/view.go index 62d7af8..bc83957 100644 --- a/internal/cmd/issue/view.go +++ b/internal/cmd/issue/view.go @@ -2,15 +2,15 @@ package issue import ( "context" - "encoding/json" "fmt" "time" "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/browser" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/browser" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type viewOptions struct { @@ -70,13 +70,13 @@ func runView(opts *viewOptions, args []string) error { } // Resolve repository - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -127,8 +127,8 @@ func outputViewJSON(streams *iostreams.IOStreams, issue *api.Issue, comments []a "state": issue.State, "kind": issue.Kind, "priority": issue.Priority, - "reporter": getUserDisplayName(issue.Reporter), - "assignee": getUserDisplayName(issue.Assignee), + "reporter": cmdutil.GetUserDisplayName(issue.Reporter), + "assignee": cmdutil.GetUserDisplayName(issue.Assignee), "votes": issue.Votes, "created_on": issue.CreatedOn, "updated_on": issue.UpdatedOn, @@ -147,7 +147,7 @@ func outputViewJSON(streams *iostreams.IOStreams, issue *api.Issue, comments []a for i, c := range comments { commentList[i] = map[string]interface{}{ "id": c.ID, - "user": getUserDisplayName(c.User), + "user": cmdutil.GetUserDisplayName(c.User), "created_on": c.CreatedOn, "updated_on": c.UpdatedOn, } @@ -158,13 +158,7 @@ func outputViewJSON(streams *iostreams.IOStreams, issue *api.Issue, comments []a output["comments"] = commentList } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func displayIssue(streams *iostreams.IOStreams, issue *api.Issue, comments []api.IssueComment, showComments bool) error { @@ -179,8 +173,8 @@ func displayIssue(streams *iostreams.IOStreams, issue *api.Issue, comments []api fmt.Fprintln(streams.Out) // Reporter and Assignee - fmt.Fprintf(streams.Out, "Reporter: %s\n", getUserDisplayName(issue.Reporter)) - fmt.Fprintf(streams.Out, "Assignee: %s\n", getUserDisplayName(issue.Assignee)) + fmt.Fprintf(streams.Out, "Reporter: %s\n", cmdutil.GetUserDisplayName(issue.Reporter)) + fmt.Fprintf(streams.Out, "Assignee: %s\n", cmdutil.GetUserDisplayName(issue.Assignee)) fmt.Fprintln(streams.Out) // Votes @@ -197,8 +191,8 @@ func displayIssue(streams *iostreams.IOStreams, issue *api.Issue, comments []api } // Timestamps - fmt.Fprintf(streams.Out, "Created: %s\n", timeAgo(issue.CreatedOn)) - fmt.Fprintf(streams.Out, "Updated: %s\n", timeAgo(issue.UpdatedOn)) + fmt.Fprintf(streams.Out, "Created: %s\n", cmdutil.TimeAgo(issue.CreatedOn)) + fmt.Fprintf(streams.Out, "Updated: %s\n", cmdutil.TimeAgo(issue.UpdatedOn)) // URL if issue.Links != nil && issue.Links.HTML != nil { @@ -213,8 +207,8 @@ func displayIssue(streams *iostreams.IOStreams, issue *api.Issue, comments []api fmt.Fprintln(streams.Out) for _, comment := range comments { - author := getUserDisplayName(comment.User) - timestamp := timeAgo(comment.CreatedOn) + author := cmdutil.GetUserDisplayName(comment.User) + timestamp := cmdutil.TimeAgo(comment.CreatedOn) if streams.ColorEnabled() { fmt.Fprintf(streams.Out, "%s%s%s commented %s:\n", iostreams.Bold, author, iostreams.Reset, timestamp) diff --git a/internal/cmd/pipeline/list.go b/internal/cmd/pipeline/list.go index 416a9b6..6552372 100644 --- a/internal/cmd/pipeline/list.go +++ b/internal/cmd/pipeline/list.go @@ -2,15 +2,15 @@ package pipeline import ( "context" - "encoding/json" "fmt" "text/tabwriter" "time" "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // ListOptions holds the options for the list command @@ -70,13 +70,13 @@ by pipeline status (PENDING, IN_PROGRESS, COMPLETED, FAILED, etc.).`, func runList(ctx context.Context, opts *ListOptions) error { // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } // Parse repository - workspace, repoSlug, err := parseRepository(opts.Repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.Repo) if err != nil { return err } @@ -168,13 +168,7 @@ func outputListJSON(streams *iostreams.IOStreams, pipelines []api.Pipeline) erro } } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputListTable(streams *iostreams.IOStreams, pipelines []api.Pipeline) error { @@ -182,11 +176,7 @@ func outputListTable(streams *iostreams.IOStreams, pipelines []api.Pipeline) err // Print header header := "#\tSTATUS\tBRANCH\tCOMMIT\tTRIGGER\tDURATION\tSTARTED" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, p := range pipelines { @@ -196,7 +186,7 @@ func outputListTable(streams *iostreams.IOStreams, pipelines []api.Pipeline) err branch := "-" commit := "-" if p.Target != nil { - branch = truncateString(p.Target.RefName, 25) + branch = cmdutil.TruncateString(p.Target.RefName, 25) if p.Target.Commit != nil { commit = getCommitShort(p.Target.Commit.Hash) } @@ -204,7 +194,7 @@ func outputListTable(streams *iostreams.IOStreams, pipelines []api.Pipeline) err trigger := getTriggerType(p.Trigger) duration := formatDuration(p.BuildSecondsUsed) - started := formatTimeAgo(p.CreatedOn) + started := cmdutil.TimeAgo(p.CreatedOn) fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", buildNum, status, branch, commit, trigger, duration, started) diff --git a/internal/cmd/pipeline/logs.go b/internal/cmd/pipeline/logs.go index 454e097..f1ef406 100644 --- a/internal/cmd/pipeline/logs.go +++ b/internal/cmd/pipeline/logs.go @@ -8,8 +8,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // LogsOptions holds the options for the logs command @@ -59,13 +60,13 @@ Step numbers can be obtained from 'bb pipeline steps'.`, func runLogs(ctx context.Context, opts *LogsOptions, pipelineArg string) error { // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } // Parse repository - workspace, repoSlug, err := parseRepository(opts.Repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.Repo) if err != nil { return err } diff --git a/internal/cmd/pipeline/pipeline.go b/internal/cmd/pipeline/pipeline.go index f582b61..5dce318 100644 --- a/internal/cmd/pipeline/pipeline.go +++ b/internal/cmd/pipeline/pipeline.go @@ -3,7 +3,7 @@ package pipeline import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdPipeline creates the pipeline command and its subcommands diff --git a/internal/cmd/pipeline/run.go b/internal/cmd/pipeline/run.go index 9409f13..e079206 100644 --- a/internal/cmd/pipeline/run.go +++ b/internal/cmd/pipeline/run.go @@ -7,9 +7,10 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/git" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/git" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type runOptions struct { @@ -63,7 +64,7 @@ pipeline defined in bitbucket-pipelines.yml with --custom.`, func runPipelineRun(opts *runOptions) error { // Resolve repository - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } @@ -85,7 +86,7 @@ func runPipelineRun(opts *runOptions) error { pipelineOpts := buildPipelineRunOptions(branch, opts.commit, opts.custom) // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/pipeline/shared.go b/internal/cmd/pipeline/shared.go index d0b9278..f0271c5 100644 --- a/internal/cmd/pipeline/shared.go +++ b/internal/cmd/pipeline/shared.go @@ -2,70 +2,15 @@ package pipeline import ( "context" - "encoding/json" "fmt" "strconv" "strings" "time" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/git" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) -// getAPIClient creates an authenticated API client -func getAPIClient() (*api.Client, error) { - hosts, err := config.LoadHostsConfig() - if err != nil { - return nil, fmt.Errorf("failed to load hosts config: %w", err) - } - - user := hosts.GetActiveUser(config.DefaultHost) - if user == "" { - return nil, fmt.Errorf("not logged in. Run 'bb auth login' to authenticate") - } - - tokenData, _, err := config.GetTokenFromEnvOrKeyring(config.DefaultHost, user) - if err != nil { - return nil, fmt.Errorf("failed to get token: %w", err) - } - - // Try to parse as JSON (OAuth token) or use as plain token - var tokenResp struct { - AccessToken string `json:"access_token"` - } - token := tokenData - if err := json.Unmarshal([]byte(tokenData), &tokenResp); err == nil && tokenResp.AccessToken != "" { - token = tokenResp.AccessToken - } - - return api.NewClient(api.WithToken(token)), nil -} - -// parseRepository parses a repository string or detects from git remote -func parseRepository(repoFlag string) (workspace, repoSlug string, err error) { - if repoFlag != "" { - parts := strings.SplitN(repoFlag, "/", 2) - if len(parts) != 2 { - return "", "", fmt.Errorf("invalid repository format: %s (expected workspace/repo)", repoFlag) - } - // Validate both parts are non-empty - if parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("invalid repository format: %s (workspace and repo cannot be empty)", repoFlag) - } - return parts[0], parts[1], nil - } - - // Detect from git - remote, err := git.GetDefaultRemote() - if err != nil { - return "", "", fmt.Errorf("could not detect repository: %w\nUse --repo WORKSPACE/REPO to specify", err) - } - - return remote.Workspace, remote.RepoSlug, nil -} - // parsePipelineIdentifier parses a pipeline build number or UUID from args func parsePipelineIdentifier(args []string) (string, error) { if len(args) == 0 { @@ -153,61 +98,6 @@ func formatDuration(seconds int) string { return fmt.Sprintf("%ds", secs) } -// formatTimeAgo formats a time as a human-readable relative time -func formatTimeAgo(t time.Time) string { - if t.IsZero() { - return "-" - } - - duration := time.Since(t) - - switch { - case duration < time.Minute: - return "just now" - case duration < time.Hour: - mins := int(duration.Minutes()) - if mins == 1 { - return "1 minute ago" - } - return fmt.Sprintf("%d minutes ago", mins) - case duration < 24*time.Hour: - hours := int(duration.Hours()) - if hours == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", hours) - case duration < 30*24*time.Hour: - days := int(duration.Hours() / 24) - if days == 1 { - return "1 day ago" - } - return fmt.Sprintf("%d days ago", days) - case duration < 365*24*time.Hour: - months := int(duration.Hours() / 24 / 30) - if months == 1 { - return "1 month ago" - } - return fmt.Sprintf("%d months ago", months) - default: - years := int(duration.Hours() / 24 / 365) - if years == 1 { - return "1 year ago" - } - return fmt.Sprintf("%d years ago", years) - } -} - -// truncateString truncates a string to a maximum length -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} - // getCommitShort returns the first 7 characters of a commit hash func getCommitShort(hash string) string { if len(hash) > 7 { diff --git a/internal/cmd/pipeline/steps.go b/internal/cmd/pipeline/steps.go index 31ad636..4b628fc 100644 --- a/internal/cmd/pipeline/steps.go +++ b/internal/cmd/pipeline/steps.go @@ -9,8 +9,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // StepsOptions holds the options for the steps command @@ -59,13 +60,13 @@ that step's logs.`, func runSteps(ctx context.Context, opts *StepsOptions, pipelineArg string) error { // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } // Parse repository - workspace, repoSlug, err := parseRepository(opts.Repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.Repo) if err != nil { return err } @@ -99,8 +100,6 @@ func runSteps(ctx context.Context, opts *StepsOptions, pipelineArg string) error return outputStepsTable(opts.Streams, result.Values) } - - func outputStepsJSON(streams *iostreams.IOStreams, steps []api.PipelineStep) error { output := make([]map[string]interface{}, len(steps)) for i, step := range steps { @@ -154,7 +153,7 @@ func outputStepsTable(streams *iostreams.IOStreams, steps []api.PipelineStep) er if name == "" { name = "(unnamed)" } - name = truncateString(name, 40) + name = cmdutil.TruncateString(name, 40) status := formatStepStatus(streams, step.State) duration := formatStepDuration(step.StartedOn, step.CompletedOn) diff --git a/internal/cmd/pipeline/stop.go b/internal/cmd/pipeline/stop.go index 9c04b06..2dcfb32 100644 --- a/internal/cmd/pipeline/stop.go +++ b/internal/cmd/pipeline/stop.go @@ -11,7 +11,8 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type stopOptions struct { @@ -59,7 +60,7 @@ You will be prompted to confirm the stop action unless the --yes flag is provide func runPipelineStop(opts *stopOptions) error { // Resolve repository - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } @@ -71,7 +72,7 @@ func runPipelineStop(opts *stopOptions) error { } // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/pipeline/view.go b/internal/cmd/pipeline/view.go index b0db04d..cb7eed8 100644 --- a/internal/cmd/pipeline/view.go +++ b/internal/cmd/pipeline/view.go @@ -8,9 +8,10 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/browser" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/browser" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // ViewOptions holds the options for the view command @@ -64,13 +65,13 @@ You can specify a pipeline by its build number or UUID.`, func runView(ctx context.Context, opts *ViewOptions) error { // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } // Parse repository - workspace, repoSlug, err := parseRepository(opts.Repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.Repo) if err != nil { return err } @@ -113,8 +114,6 @@ func runView(ctx context.Context, opts *ViewOptions) error { return displayPipeline(opts.Streams, pipeline, steps) } - - func getPipelineWebURL(workspace, repoSlug string, buildNumber int) string { return fmt.Sprintf("https://bitbucket.org/%s/%s/pipelines/results/%d", workspace, repoSlug, buildNumber) @@ -228,9 +227,9 @@ func displayPipeline(streams *iostreams.IOStreams, pipeline *api.Pipeline, steps } // Timestamps - fmt.Fprintf(streams.Out, "Started: %s\n", formatTimeAgo(pipeline.CreatedOn)) + fmt.Fprintf(streams.Out, "Started: %s\n", cmdutil.TimeAgo(pipeline.CreatedOn)) if pipeline.CompletedOn != nil && !pipeline.CompletedOn.IsZero() { - fmt.Fprintf(streams.Out, "Completed: %s\n", formatTimeAgo(*pipeline.CompletedOn)) + fmt.Fprintf(streams.Out, "Completed: %s\n", cmdutil.TimeAgo(*pipeline.CompletedOn)) } // Steps summary diff --git a/internal/cmd/pr/checkout.go b/internal/cmd/pr/checkout.go index 766797c..702338a 100644 --- a/internal/cmd/pr/checkout.go +++ b/internal/cmd/pr/checkout.go @@ -10,8 +10,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/git" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/git" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type checkoutOptions struct { @@ -68,7 +69,7 @@ use --force to overwrite it.`, func runCheckout(opts *checkoutOptions) error { // Resolve repository - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } @@ -76,7 +77,7 @@ func runCheckout(opts *checkoutOptions) error { opts.streams.Info("Fetching pull request #%d...", opts.prNumber) // Get authenticated API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -85,7 +86,7 @@ func runCheckout(opts *checkoutOptions) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - pr, err := getPullRequest(ctx, client, workspace, repoSlug, opts.prNumber) + pr, err := client.GetPullRequest(ctx, workspace, repoSlug, int64(opts.prNumber)) if err != nil { return fmt.Errorf("failed to get pull request: %w", err) } diff --git a/internal/cmd/pr/checks.go b/internal/cmd/pr/checks.go index 8626da3..8dccc82 100644 --- a/internal/cmd/pr/checks.go +++ b/internal/cmd/pr/checks.go @@ -10,8 +10,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // ChecksOptions holds the options for the checks command @@ -63,13 +64,13 @@ associated with the pull request.`, func runChecks(ctx context.Context, opts *ChecksOptions) error { // Parse repository - workspace, repoSlug, err := parseRepository(opts.Repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.Repo) if err != nil { return err } // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -138,7 +139,7 @@ func outputChecksTable(streams *iostreams.IOStreams, statuses []api.CommitStatus if name == "" { name = s.Key } - desc := truncateString(s.Description, 50) + desc := cmdutil.TruncateString(s.Description, 50) fmt.Fprintf(w, "%s\t%s\t%s\n", status, name, desc) } diff --git a/internal/cmd/pr/close.go b/internal/cmd/pr/close.go index 9ae0f31..20f2c8a 100644 --- a/internal/cmd/pr/close.go +++ b/internal/cmd/pr/close.go @@ -6,7 +6,8 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type closeOptions struct { @@ -54,12 +55,12 @@ func runClose(opts *closeOptions, args []string) error { return err } - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/pr/comment.go b/internal/cmd/pr/comment.go index 7f30c09..497ab7f 100644 --- a/internal/cmd/pr/comment.go +++ b/internal/cmd/pr/comment.go @@ -6,8 +6,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type commentOptions struct { @@ -55,7 +56,7 @@ func runComment(opts *commentOptions, args []string) error { return err } - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } @@ -72,7 +73,7 @@ func runComment(opts *commentOptions, args []string) error { opts.body = body } - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -93,7 +94,7 @@ func runComment(opts *commentOptions, args []string) error { } // Parse response to get comment ID - comment, err := api.ParseResponse[*PRComment](resp) + comment, err := api.ParseResponse[*api.PRComment](resp) if err != nil { // Still print success even if we can't parse the comment ID opts.streams.Success("Added comment to pull request #%d", prNum) diff --git a/internal/cmd/pr/create.go b/internal/cmd/pr/create.go index 09fd84d..5344f0a 100644 --- a/internal/cmd/pr/create.go +++ b/internal/cmd/pr/create.go @@ -13,10 +13,11 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/browser" - "github.com/rbansal42/bb/internal/git" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/browser" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/git" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type createOptions struct { @@ -87,7 +88,7 @@ If --body is not provided, an editor will open for you to write the description. func runCreate(opts *createOptions) error { // Resolve repository - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } @@ -106,7 +107,7 @@ func runCreate(opts *createOptions) error { } // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/pr/diff.go b/internal/cmd/pr/diff.go index 81b0483..ab058f9 100644 --- a/internal/cmd/pr/diff.go +++ b/internal/cmd/pr/diff.go @@ -10,8 +10,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type diffOptions struct { @@ -59,12 +60,12 @@ func runDiff(opts *diffOptions, args []string) error { return err } - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -72,7 +73,7 @@ func runDiff(opts *diffOptions, args []string) error { ctx := context.Background() // Get the PR to get the diff link - pr, err := getPullRequest(ctx, client, workspace, repoSlug, prNum) + pr, err := client.GetPullRequest(ctx, workspace, repoSlug, int64(prNum)) if err != nil { return fmt.Errorf("failed to get pull request: %w", err) } diff --git a/internal/cmd/pr/edit.go b/internal/cmd/pr/edit.go index b3676ad..f6af3c0 100644 --- a/internal/cmd/pr/edit.go +++ b/internal/cmd/pr/edit.go @@ -9,8 +9,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type editOptions struct { @@ -76,13 +77,13 @@ func runEdit(ctx context.Context, opts *editOptions) error { } // Parse repository - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/pr/list.go b/internal/cmd/pr/list.go index a949c7f..8cb5fb3 100644 --- a/internal/cmd/pr/list.go +++ b/internal/cmd/pr/list.go @@ -2,25 +2,25 @@ package pr import ( "context" - "encoding/json" "fmt" "strings" "text/tabwriter" "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // ListOptions holds the options for the list command type ListOptions struct { - State string - Author string - Limit int - JSON bool - Repo string - Streams *iostreams.IOStreams + State string + Author string + Limit int + JSON bool + Repo string + Streams *iostreams.IOStreams } // NewCmdList creates the pr list command @@ -70,13 +70,13 @@ by state (OPEN, MERGED, DECLINED).`, func runList(ctx context.Context, opts *ListOptions) error { // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } // Parse repository - workspace, repoSlug, err := parseRepository(opts.Repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.Repo) if err != nil { return err } @@ -124,13 +124,7 @@ func outputListJSON(streams *iostreams.IOStreams, prs []api.PullRequest) error { output[i] = api.PullRequestJSON{PullRequest: &prs[i]} } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputTable(streams *iostreams.IOStreams, prs []api.PullRequest) error { @@ -138,17 +132,13 @@ func outputTable(streams *iostreams.IOStreams, prs []api.PullRequest) error { // Print header header := "ID\tTITLE\tBRANCH\tAUTHOR\tSTATUS" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, pr := range prs { - title := truncateString(pr.Title, 50) - branch := truncateString(pr.Source.Branch.Name, 30) - author := truncateString(pr.Author.DisplayName, 20) + title := cmdutil.TruncateString(pr.Title, 50) + branch := cmdutil.TruncateString(pr.Source.Branch.Name, 30) + author := cmdutil.TruncateString(pr.Author.DisplayName, 20) status := formatStatus(streams, string(pr.State)) fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", @@ -174,13 +164,3 @@ func formatStatus(streams *iostreams.IOStreams, state string) string { return state } } - -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} diff --git a/internal/cmd/pr/merge.go b/internal/cmd/pr/merge.go index 0829d2b..b8cd1db 100644 --- a/internal/cmd/pr/merge.go +++ b/internal/cmd/pr/merge.go @@ -10,9 +10,10 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/git" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/git" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type mergeOptions struct { @@ -106,13 +107,13 @@ may not support rebase merge for all repositories).`, func runMerge(opts *mergeOptions) error { // Resolve repository - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } // Get authenticated API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -135,13 +136,13 @@ func runMerge(opts *mergeOptions) error { } // Get PR details - pr, err := getPullRequest(ctx, client, workspace, repoSlug, opts.prNumber) + pr, err := client.GetPullRequest(ctx, workspace, repoSlug, int64(opts.prNumber)) if err != nil { return fmt.Errorf("failed to get pull request: %w", err) } // Check PR state - if pr.State != "OPEN" { + if pr.State != api.PRStateOpen { return fmt.Errorf("pull request #%d is not open (state: %s)", opts.prNumber, pr.State) } diff --git a/internal/cmd/pr/pr.go b/internal/cmd/pr/pr.go index b1989ce..b007262 100644 --- a/internal/cmd/pr/pr.go +++ b/internal/cmd/pr/pr.go @@ -3,7 +3,7 @@ package pr import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdPR creates the pr command and its subcommands diff --git a/internal/cmd/pr/pr_test.go b/internal/cmd/pr/pr_test.go index 655456b..2e6acde 100644 --- a/internal/cmd/pr/pr_test.go +++ b/internal/cmd/pr/pr_test.go @@ -2,6 +2,9 @@ package pr import ( "testing" + + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" ) func TestParsePRNumber(t *testing.T) { @@ -153,15 +156,15 @@ func TestParseRepository(t *testing.T) { wantErr: true, // empty repo is invalid }, { - name: "empty flag falls back to git detection", - repoFlag: "", - wantErr: true, // Will error in test environment without git + name: "empty flag falls back to git detection", + repoFlag: "", + wantErr: true, // Will error in test environment without git }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - workspace, repo, err := parseRepository(tt.repoFlag) + workspace, repo, err := cmdutil.ParseRepository(tt.repoFlag) if (err != nil) != tt.wantErr { t.Errorf("parseRepository() error = %v, wantErr %v", err, tt.wantErr) @@ -187,7 +190,7 @@ func TestGetEditor(t *testing.T) { // Test that getEditor returns a non-empty string // The actual value depends on environment variables editor := getEditor() - + if editor == "" { t.Error("getEditor() returned empty string") } @@ -200,7 +203,7 @@ func TestGetEditorPriority(t *testing.T) { // Store original values // Note: In a real test, we'd use t.Setenv() which automatically restores // For now, just test that the function returns a non-empty value - + editor := getEditor() if editor == "" { t.Error("expected non-empty editor") @@ -209,12 +212,12 @@ func TestGetEditorPriority(t *testing.T) { // TestPullRequestTypes verifies the PR types can be used correctly func TestPullRequestTypes(t *testing.T) { - // Test that PullRequest struct can be instantiated - pr := PullRequest{ + // Test that api.PullRequest struct can be instantiated + pr := api.PullRequest{ ID: 1, Title: "Test PR", Description: "Test description", - State: "OPEN", + State: api.PRStateOpen, } if pr.ID != 1 { @@ -237,9 +240,9 @@ func TestPullRequestTypes(t *testing.T) { } } -// TestPRCommentTypes verifies the PRComment struct +// TestPRCommentTypes verifies the api.PRComment struct func TestPRCommentTypes(t *testing.T) { - comment := PRComment{ + comment := api.PRComment{ ID: 42, } comment.Content.Raw = "This is a comment" @@ -261,9 +264,9 @@ func TestPRCommentTypes(t *testing.T) { // TestParsePRNumberErrorMessages verifies error message quality func TestParsePRNumberErrorMessages(t *testing.T) { tests := []struct { - name string - args []string - wantErrMsg string + name string + args []string + wantErrMsg string }{ { name: "empty args error message", @@ -293,7 +296,7 @@ func TestParsePRNumberErrorMessages(t *testing.T) { // TestParseRepositoryErrorMessages verifies error message quality func TestParseRepositoryErrorMessages(t *testing.T) { - _, _, err := parseRepository("invalid-format") + _, _, err := cmdutil.ParseRepository("invalid-format") if err == nil { t.Fatal("expected error but got nil") } diff --git a/internal/cmd/pr/reopen.go b/internal/cmd/pr/reopen.go index c3570e3..a31c334 100644 --- a/internal/cmd/pr/reopen.go +++ b/internal/cmd/pr/reopen.go @@ -6,7 +6,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type reopenOptions struct { @@ -48,12 +50,12 @@ func runReopen(opts *reopenOptions, args []string) error { return err } - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -61,12 +63,12 @@ func runReopen(opts *reopenOptions, args []string) error { ctx := context.Background() // First, check if PR is declined - pr, err := getPullRequest(ctx, client, workspace, repoSlug, prNum) + pr, err := client.GetPullRequest(ctx, workspace, repoSlug, int64(prNum)) if err != nil { return fmt.Errorf("failed to get pull request: %w", err) } - if pr.State != "DECLINED" { + if pr.State != api.PRStateDeclined { return fmt.Errorf("pull request #%d is not declined (current state: %s)", prNum, pr.State) } diff --git a/internal/cmd/pr/review.go b/internal/cmd/pr/review.go index ca9a611..c916212 100644 --- a/internal/cmd/pr/review.go +++ b/internal/cmd/pr/review.go @@ -6,7 +6,8 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type reviewOptions struct { @@ -73,12 +74,12 @@ func runReview(opts *reviewOptions, args []string) error { return err } - workspace, repoSlug, err := parseRepository(opts.repo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) if err != nil { return err } - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/pr/shared.go b/internal/cmd/pr/shared.go index 9ca5fb8..e9daf6a 100644 --- a/internal/cmd/pr/shared.go +++ b/internal/cmd/pr/shared.go @@ -1,71 +1,15 @@ package pr import ( - "context" - "encoding/json" "fmt" "os" "os/exec" "strconv" "strings" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/git" + "github.com/rbansal42/bitbucket-cli/internal/config" ) -// getAPIClient creates an authenticated API client -func getAPIClient() (*api.Client, error) { - hosts, err := config.LoadHostsConfig() - if err != nil { - return nil, fmt.Errorf("failed to load hosts config: %w", err) - } - - user := hosts.GetActiveUser(config.DefaultHost) - if user == "" { - return nil, fmt.Errorf("not logged in. Run 'bb auth login' to authenticate") - } - - tokenData, _, err := config.GetTokenFromEnvOrKeyring(config.DefaultHost, user) - if err != nil { - return nil, fmt.Errorf("failed to get token: %w", err) - } - - // Try to parse as JSON (OAuth token) or use as plain token - var tokenResp struct { - AccessToken string `json:"access_token"` - } - token := tokenData - if err := json.Unmarshal([]byte(tokenData), &tokenResp); err == nil && tokenResp.AccessToken != "" { - token = tokenResp.AccessToken - } - - return api.NewClient(api.WithToken(token)), nil -} - -// parseRepository parses a repository string or detects from git remote -func parseRepository(repoFlag string) (workspace, repoSlug string, err error) { - if repoFlag != "" { - parts := strings.SplitN(repoFlag, "/", 2) - if len(parts) != 2 { - return "", "", fmt.Errorf("invalid repository format: %s (expected workspace/repo)", repoFlag) - } - // Validate both parts are non-empty - if parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("invalid repository format: %s (workspace and repo cannot be empty)", repoFlag) - } - return parts[0], parts[1], nil - } - - // Detect from git - remote, err := git.GetDefaultRemote() - if err != nil { - return "", "", fmt.Errorf("could not detect repository: %w\nUse --repo WORKSPACE/REPO to specify", err) - } - - return remote.Workspace, remote.RepoSlug, nil -} - // parsePRNumber parses a PR number from args or returns an error func parsePRNumber(args []string) (int, error) { if len(args) == 0 { @@ -147,95 +91,3 @@ func getEditor() string { // Default to vi return "vi" } - -// PRUser represents a user in a pull request context -type PRUser struct { - UUID string `json:"uuid"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - AccountID string `json:"account_id"` - Nickname string `json:"nickname"` - Links struct { - Avatar struct { - Href string `json:"href"` - } `json:"avatar"` - HTML struct { - Href string `json:"href"` - } `json:"html"` - } `json:"links"` -} - -// PRParticipant represents a participant in a pull request -type PRParticipant struct { - User PRUser `json:"user"` - Role string `json:"role"` // PARTICIPANT, REVIEWER - Approved bool `json:"approved"` - State string `json:"state"` // approved, changes_requested, etc. -} - -// PullRequest represents a Bitbucket pull request -type PullRequest struct { - ID int `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - State string `json:"state"` - Author PRUser `json:"author"` - Source struct { - Branch struct { - Name string `json:"name"` - } `json:"branch"` - Repository struct { - FullName string `json:"full_name"` - } `json:"repository"` - } `json:"source"` - Destination struct { - Branch struct { - Name string `json:"name"` - } `json:"branch"` - Repository struct { - FullName string `json:"full_name"` - } `json:"repository"` - } `json:"destination"` - Reviewers []PRUser `json:"reviewers"` - Participants []PRParticipant `json:"participants"` - CommentCount int `json:"comment_count"` - TaskCount int `json:"task_count"` - CloseSourceBranch bool `json:"close_source_branch"` - CreatedOn string `json:"created_on"` - UpdatedOn string `json:"updated_on"` - Links struct { - HTML struct { - Href string `json:"href"` - } `json:"html"` - Diff struct { - Href string `json:"href"` - } `json:"diff"` - Self struct { - Href string `json:"href"` - } `json:"self"` - } `json:"links"` -} - -// PRComment represents a pull request comment -type PRComment struct { - ID int `json:"id"` - Content struct { - Raw string `json:"raw"` - } `json:"content"` - Links struct { - HTML struct { - Href string `json:"href"` - } `json:"html"` - } `json:"links"` -} - -// getPullRequest fetches a pull request by number -func getPullRequest(ctx context.Context, client *api.Client, workspace, repoSlug string, prNum int) (*PullRequest, error) { - path := fmt.Sprintf("/repositories/%s/%s/pullrequests/%d", workspace, repoSlug, prNum) - resp, err := client.Get(ctx, path, nil) - if err != nil { - return nil, err - } - - return api.ParseResponse[*PullRequest](resp) -} diff --git a/internal/cmd/pr/view.go b/internal/cmd/pr/view.go index ddcc715..9958555 100644 --- a/internal/cmd/pr/view.go +++ b/internal/cmd/pr/view.go @@ -12,9 +12,11 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/browser" - "github.com/rbansal42/bb/internal/git" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/browser" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/git" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type viewOptions struct { @@ -86,13 +88,13 @@ You can specify a pull request by number, URL, or branch name.`, func runView(opts *viewOptions) error { // Resolve repository var err error - opts.workspace, opts.repoSlug, err = parseRepository(opts.repo) + opts.workspace, opts.repoSlug, err = cmdutil.ParseRepository(opts.repo) if err != nil { return err } // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -107,7 +109,7 @@ func runView(opts *viewOptions) error { } // Fetch PR details - pr, err := getPullRequest(ctx, client, opts.workspace, opts.repoSlug, prNumber) + pr, err := client.GetPullRequest(ctx, opts.workspace, opts.repoSlug, int64(prNumber)) if err != nil { return err } @@ -167,7 +169,7 @@ func extractPRNumberFromURL(urlStr string) (int, error) { // findPRForBranch finds an open PR for the given source branch func findPRForBranch(ctx context.Context, workspace, repoSlug, branch string) (int, error) { - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return 0, err } @@ -184,8 +186,8 @@ func findPRForBranch(ctx context.Context, workspace, repoSlug, branch string) (i } var result struct { - Values []PullRequest `json:"values"` - Size int `json:"size"` + Values []api.PullRequest `json:"values"` + Size int `json:"size"` } if err := json.Unmarshal(resp.Body, &result); err != nil { return 0, fmt.Errorf("failed to parse response: %w", err) @@ -195,25 +197,20 @@ func findPRForBranch(ctx context.Context, workspace, repoSlug, branch string) (i return 0, fmt.Errorf("no open pull request found for branch %q", branch) } - return result.Values[0].ID, nil + return int(result.Values[0].ID), nil } -func outputJSON(streams *iostreams.IOStreams, pr *PullRequest) error { - data, err := json.MarshalIndent(pr, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Fprintln(streams.Out, string(data)) - return nil +func outputJSON(streams *iostreams.IOStreams, pr *api.PullRequest) error { + return cmdutil.PrintJSON(streams, pr) } -func displayPR(streams *iostreams.IOStreams, pr *PullRequest) error { +func displayPR(streams *iostreams.IOStreams, pr *api.PullRequest) error { // Title and state fmt.Fprintf(streams.Out, "Title: %s\n", pr.Title) - fmt.Fprintf(streams.Out, "State: %s\n", strings.ToUpper(pr.State)) + fmt.Fprintf(streams.Out, "State: %s\n", strings.ToUpper(string(pr.State))) // Author - authorName := getUserDisplayName(pr.Author) + authorName := cmdutil.GetUserDisplayName(&pr.Author) fmt.Fprintf(streams.Out, "Author: %s\n", authorName) // Description @@ -230,7 +227,7 @@ func displayPR(streams *iostreams.IOStreams, pr *PullRequest) error { fmt.Fprintln(streams.Out, "Reviewers:") for _, p := range pr.Participants { if p.Role == "REVIEWER" { - name := getUserDisplayName(p.User) + name := cmdutil.GetUserDisplayName(&p.User) status := "pending" if p.Approved { status = "approved" @@ -252,64 +249,7 @@ func displayPR(streams *iostreams.IOStreams, pr *PullRequest) error { fmt.Fprintf(streams.Out, "Comments: %d\n", pr.CommentCount) // Created date - createdAt, err := time.Parse(time.RFC3339, pr.CreatedOn) - if err == nil { - fmt.Fprintf(streams.Out, "Created: %s\n", timeAgo(createdAt)) - } + fmt.Fprintf(streams.Out, "Created: %s\n", cmdutil.TimeAgo(pr.CreatedOn)) return nil } - -// getUserDisplayName returns the best available display name for a user -func getUserDisplayName(user PRUser) string { - if user.DisplayName != "" { - return user.DisplayName - } - if user.Username != "" { - return user.Username - } - if user.Nickname != "" { - return user.Nickname - } - return "unknown" -} - -// timeAgo returns a human-readable relative time string -func timeAgo(t time.Time) string { - duration := time.Since(t) - - switch { - case duration < time.Minute: - return "just now" - case duration < time.Hour: - mins := int(duration.Minutes()) - if mins == 1 { - return "1 minute ago" - } - return fmt.Sprintf("%d minutes ago", mins) - case duration < 24*time.Hour: - hours := int(duration.Hours()) - if hours == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", hours) - case duration < 30*24*time.Hour: - days := int(duration.Hours() / 24) - if days == 1 { - return "1 day ago" - } - return fmt.Sprintf("%d days ago", days) - case duration < 365*24*time.Hour: - months := int(duration.Hours() / 24 / 30) - if months == 1 { - return "1 month ago" - } - return fmt.Sprintf("%d months ago", months) - default: - years := int(duration.Hours() / 24 / 365) - if years == 1 { - return "1 year ago" - } - return fmt.Sprintf("%d years ago", years) - } -} diff --git a/internal/cmd/project/create.go b/internal/cmd/project/create.go index a8f9fe5..e1855a2 100644 --- a/internal/cmd/project/create.go +++ b/internal/cmd/project/create.go @@ -8,8 +8,10 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type createOptions struct { @@ -47,7 +49,13 @@ identifier (e.g., "PROJ", "DEV", "CORE").`, bb project create -w myworkspace -k CORE -n "Core" --json`, RunE: func(cmd *cobra.Command, args []string) error { if opts.workspace == "" { - return fmt.Errorf("workspace is required. Use --workspace or -w to specify") + defaultWs, err := config.GetDefaultWorkspace() + if err == nil && defaultWs != "" { + opts.workspace = defaultWs + } + } + if opts.workspace == "" { + return fmt.Errorf("workspace is required. Use --workspace or -w to specify, or set a default with 'bb workspace set-default'") } if opts.key == "" { return fmt.Errorf("project key is required. Use --key or -k to specify") @@ -72,7 +80,7 @@ identifier (e.g., "PROJ", "DEV", "CORE").`, func runCreate(ctx context.Context, opts *createOptions) error { // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/project/list.go b/internal/cmd/project/list.go index 2335da5..caf332a 100644 --- a/internal/cmd/project/list.go +++ b/internal/cmd/project/list.go @@ -2,15 +2,16 @@ package project import ( "context" - "encoding/json" "fmt" "text/tabwriter" "time" "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // listOptions holds the options for the list command @@ -44,7 +45,13 @@ This command shows projects you have access to in the specified workspace.`, Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { if opts.Workspace == "" { - return fmt.Errorf("workspace is required. Use --workspace or -w to specify") + defaultWs, err := config.GetDefaultWorkspace() + if err == nil && defaultWs != "" { + opts.Workspace = defaultWs + } + } + if opts.Workspace == "" { + return fmt.Errorf("workspace is required. Use --workspace or -w to specify, or set a default with 'bb workspace set-default'") } return runList(cmd.Context(), opts) }, @@ -63,7 +70,7 @@ func runList(ctx context.Context, opts *listOptions) error { defer cancel() // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -108,13 +115,7 @@ func outputListJSON(streams *iostreams.IOStreams, projects []api.ProjectFull) er } } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputListTable(streams *iostreams.IOStreams, projects []api.ProjectFull) error { @@ -122,17 +123,13 @@ func outputListTable(streams *iostreams.IOStreams, projects []api.ProjectFull) e // Print header header := "KEY\tNAME\tDESCRIPTION\tVISIBILITY" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, proj := range projects { key := proj.Key - name := truncateString(proj.Name, 30) - desc := truncateString(proj.Description, 40) + name := cmdutil.TruncateString(proj.Name, 30) + desc := cmdutil.TruncateString(proj.Description, 40) visibility := formatVisibility(streams, proj.IsPrivate) fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", key, name, desc, visibility) @@ -154,13 +151,3 @@ func formatVisibility(streams *iostreams.IOStreams, isPrivate bool) string { } return "public" } - -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} diff --git a/internal/cmd/project/project.go b/internal/cmd/project/project.go index 6a423ca..7ca548e 100644 --- a/internal/cmd/project/project.go +++ b/internal/cmd/project/project.go @@ -3,7 +3,7 @@ package project import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdProject creates the project command and its subcommands diff --git a/internal/cmd/project/shared.go b/internal/cmd/project/shared.go index be8ca3a..dee8e5e 100644 --- a/internal/cmd/project/shared.go +++ b/internal/cmd/project/shared.go @@ -1,38 +1,4 @@ package project -import ( - "encoding/json" - "fmt" - - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/config" -) - -// getAPIClient creates an authenticated API client -func getAPIClient() (*api.Client, error) { - hosts, err := config.LoadHostsConfig() - if err != nil { - return nil, fmt.Errorf("failed to load hosts config: %w", err) - } - - user := hosts.GetActiveUser(config.DefaultHost) - if user == "" { - return nil, fmt.Errorf("not logged in. Run 'bb auth login' to authenticate") - } - - tokenData, _, err := config.GetTokenFromEnvOrKeyring(config.DefaultHost, user) - if err != nil { - return nil, fmt.Errorf("failed to get token: %w", err) - } - - // Try to parse as JSON (OAuth token) or use as plain token - var tokenResp struct { - AccessToken string `json:"access_token"` - } - token := tokenData - if err := json.Unmarshal([]byte(tokenData), &tokenResp); err == nil && tokenResp.AccessToken != "" { - token = tokenResp.AccessToken - } - - return api.NewClient(api.WithToken(token)), nil -} +// Package project uses cmdutil for shared functionality. +// Import cmdutil in individual command files that need GetAPIClient. diff --git a/internal/cmd/project/view.go b/internal/cmd/project/view.go index a3b2f80..7f48d1e 100644 --- a/internal/cmd/project/view.go +++ b/internal/cmd/project/view.go @@ -8,9 +8,11 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/browser" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/browser" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type viewOptions struct { @@ -47,7 +49,13 @@ short uppercase identifiers like "PROJ" or "DEV".`, opts.key = args[0] if opts.workspace == "" { - return fmt.Errorf("workspace is required. Use --workspace or -w to specify") + defaultWs, err := config.GetDefaultWorkspace() + if err == nil && defaultWs != "" { + opts.workspace = defaultWs + } + } + if opts.workspace == "" { + return fmt.Errorf("workspace is required. Use --workspace or -w to specify, or set a default with 'bb workspace set-default'") } return runView(cmd.Context(), opts) @@ -63,7 +71,7 @@ short uppercase identifiers like "PROJ" or "DEV".`, func runView(ctx context.Context, opts *viewOptions) error { // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/repo/clone.go b/internal/cmd/repo/clone.go index b601cab..ce2b9a8 100644 --- a/internal/cmd/repo/clone.go +++ b/internal/cmd/repo/clone.go @@ -11,7 +11,8 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type cloneOptions struct { @@ -85,13 +86,13 @@ func runClone(opts *cloneOptions) error { } } else { // Parse workspace/repo format - workspace, repoSlug, err := parseRepoArg(opts.repoArg) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repoArg) if err != nil { return err } // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/repo/create.go b/internal/cmd/repo/create.go index fda548e..ccd2971 100644 --- a/internal/cmd/repo/create.go +++ b/internal/cmd/repo/create.go @@ -11,10 +11,11 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/git" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/git" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type createOptions struct { @@ -102,7 +103,7 @@ a public repository instead.`, func runCreate(opts *createOptions) error { // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -113,9 +114,16 @@ func runCreate(opts *createOptions) error { // Determine workspace workspace := opts.workspace if workspace == "" { - workspace, err = getDefaultWorkspace(ctx, client, opts.streams) - if err != nil { - return fmt.Errorf("could not determine workspace: %w\nUse --workspace to specify", err) + // First, try to get the default workspace from config + defaultWs, cfgErr := config.GetDefaultWorkspace() + if cfgErr == nil && defaultWs != "" { + workspace = defaultWs + } else { + // Fall back to inferring workspace from user + workspace, err = getDefaultWorkspace(ctx, client, opts.streams) + if err != nil { + return fmt.Errorf("could not determine workspace: %w\nUse --workspace to specify or run 'bb workspace set-default' to set a default", err) + } } } diff --git a/internal/cmd/repo/delete.go b/internal/cmd/repo/delete.go index 360ad10..ed3b08e 100644 --- a/internal/cmd/repo/delete.go +++ b/internal/cmd/repo/delete.go @@ -7,7 +7,8 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type deleteOptions struct { @@ -54,7 +55,7 @@ unless the --yes flag is provided.`, func runDelete(opts *deleteOptions) error { // Parse the repository argument var err error - opts.workspace, opts.repoSlug, err = parseRepoArg(opts.repoArg) + opts.workspace, opts.repoSlug, err = cmdutil.ParseRepository(opts.repoArg) if err != nil { return err } @@ -76,7 +77,7 @@ func runDelete(opts *deleteOptions) error { } // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/repo/delete_test.go b/internal/cmd/repo/delete_test.go index 0797d91..08b44c6 100644 --- a/internal/cmd/repo/delete_test.go +++ b/internal/cmd/repo/delete_test.go @@ -4,6 +4,8 @@ import ( "bytes" "strings" "testing" + + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" ) func TestParseRepoArg(t *testing.T) { @@ -59,10 +61,10 @@ func TestParseRepoArg(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - workspace, repo, err := parseRepoArg(tt.arg) + workspace, repo, err := cmdutil.ParseRepository(tt.arg) if (err != nil) != tt.wantErr { - t.Errorf("parseRepoArg() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("ParseRepository() error = %v, wantErr %v", err, tt.wantErr) return } @@ -71,11 +73,11 @@ func TestParseRepoArg(t *testing.T) { } if workspace != tt.wantWorkspace { - t.Errorf("parseRepoArg() workspace = %v, want %v", workspace, tt.wantWorkspace) + t.Errorf("ParseRepository() workspace = %v, want %v", workspace, tt.wantWorkspace) } if repo != tt.wantRepo { - t.Errorf("parseRepoArg() repo = %v, want %v", repo, tt.wantRepo) + t.Errorf("ParseRepository() repo = %v, want %v", repo, tt.wantRepo) } }) } @@ -101,7 +103,7 @@ func TestGetRepoName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, repo, err := parseRepoArg(tt.input) + _, repo, err := cmdutil.ParseRepository(tt.input) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/cmd/repo/fork.go b/internal/cmd/repo/fork.go index 9877cbe..df3b69f 100644 --- a/internal/cmd/repo/fork.go +++ b/internal/cmd/repo/fork.go @@ -8,8 +8,10 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/git" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/git" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type forkOptions struct { @@ -78,7 +80,7 @@ as a new remote (default name: "fork").`, func runFork(opts *forkOptions) error { // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -87,7 +89,7 @@ func runFork(opts *forkOptions) error { defer cancel() // Parse source repository - workspace, repoSlug, err := parseRepository(opts.sourceRepo) + workspace, repoSlug, err := cmdutil.ParseRepository(opts.sourceRepo) if err != nil { return err } @@ -97,11 +99,18 @@ func runFork(opts *forkOptions) error { // Determine destination workspace destWorkspace := opts.workspace + if destWorkspace == "" { + // Try to get default workspace from config + defaultWs, err := config.GetDefaultWorkspace() + if err == nil && defaultWs != "" { + destWorkspace = defaultWs + } + } if destWorkspace == "" { // Try to get current user's workspace user, err := client.GetCurrentUser(ctx) if err != nil { - return fmt.Errorf("could not determine destination workspace: %w\nUse --workspace to specify", err) + return fmt.Errorf("could not determine destination workspace: %w\nUse --workspace to specify or run 'bb workspace set-default'", err) } destWorkspace = user.Username } diff --git a/internal/cmd/repo/list.go b/internal/cmd/repo/list.go index 79f951d..db937bc 100644 --- a/internal/cmd/repo/list.go +++ b/internal/cmd/repo/list.go @@ -2,15 +2,15 @@ package repo import ( "context" - "encoding/json" "fmt" "text/tabwriter" - "time" "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // ListOptions holds the options for the list command @@ -49,7 +49,13 @@ By default, repositories are sorted by last updated time.`, Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { if opts.Workspace == "" { - return fmt.Errorf("workspace is required. Use --workspace or -w to specify") + defaultWs, err := config.GetDefaultWorkspace() + if err == nil && defaultWs != "" { + opts.Workspace = defaultWs + } + } + if opts.Workspace == "" { + return fmt.Errorf("workspace is required. Use --workspace or -w to specify, or set a default with 'bb workspace set-default'") } return runList(cmd.Context(), opts) }, @@ -65,7 +71,7 @@ By default, repositories are sorted by last updated time.`, func runList(ctx context.Context, opts *ListOptions) error { // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -111,13 +117,7 @@ func outputListJSON(streams *iostreams.IOStreams, repos []api.RepositoryFull) er } } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputTable(streams *iostreams.IOStreams, repos []api.RepositoryFull) error { @@ -125,18 +125,14 @@ func outputTable(streams *iostreams.IOStreams, repos []api.RepositoryFull) error // Print header header := "NAME\tDESCRIPTION\tVISIBILITY\tUPDATED" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, repo := range repos { - name := truncateString(repo.FullName, 40) - desc := truncateString(repo.Description, 40) + name := cmdutil.TruncateString(repo.FullName, 40) + desc := cmdutil.TruncateString(repo.Description, 40) visibility := formatVisibility(streams, repo.IsPrivate) - updated := formatUpdated(repo.UpdatedOn) + updated := cmdutil.TimeAgo(repo.UpdatedOn) fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, desc, visibility, updated) } @@ -157,57 +153,3 @@ func formatVisibility(streams *iostreams.IOStreams, isPrivate bool) string { } return "public" } - -func formatUpdated(t time.Time) string { - if t.IsZero() { - return "-" - } - - now := time.Now() - diff := now.Sub(t) - - switch { - case diff < time.Minute: - return "just now" - case diff < time.Hour: - mins := int(diff.Minutes()) - if mins == 1 { - return "1 minute ago" - } - return fmt.Sprintf("%d minutes ago", mins) - case diff < 24*time.Hour: - hours := int(diff.Hours()) - if hours == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", hours) - case diff < 30*24*time.Hour: - days := int(diff.Hours() / 24) - if days == 1 { - return "1 day ago" - } - return fmt.Sprintf("%d days ago", days) - case diff < 365*24*time.Hour: - months := int(diff.Hours() / 24 / 30) - if months == 1 { - return "1 month ago" - } - return fmt.Sprintf("%d months ago", months) - default: - years := int(diff.Hours() / 24 / 365) - if years == 1 { - return "1 year ago" - } - return fmt.Sprintf("%d years ago", years) - } -} - -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} diff --git a/internal/cmd/repo/repo.go b/internal/cmd/repo/repo.go index 07f38fa..53814eb 100644 --- a/internal/cmd/repo/repo.go +++ b/internal/cmd/repo/repo.go @@ -3,7 +3,7 @@ package repo import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdRepo creates the repo command and its subcommands diff --git a/internal/cmd/repo/repo_test.go b/internal/cmd/repo/repo_test.go index b229e45..f7a389e 100644 --- a/internal/cmd/repo/repo_test.go +++ b/internal/cmd/repo/repo_test.go @@ -3,7 +3,8 @@ package repo import ( "testing" - "github.com/rbansal42/bb/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" ) func TestParseRepositoryFormat(t *testing.T) { @@ -85,7 +86,7 @@ func TestParseRepositoryFormat(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - workspace, repo, err := parseRepository(tt.repoFlag) + workspace, repo, err := cmdutil.ParseRepository(tt.repoFlag) if (err != nil) != tt.wantErr { t.Errorf("parseRepository() error = %v, wantErr %v", err, tt.wantErr) @@ -280,7 +281,7 @@ func TestParseRepositoryErrorMessages(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, _, err := parseRepository(tt.repoFlag) + _, _, err := cmdutil.ParseRepository(tt.repoFlag) if err == nil { t.Fatal("expected error but got nil") } diff --git a/internal/cmd/repo/setdefault.go b/internal/cmd/repo/setdefault.go index e9dbad7..37575f7 100644 --- a/internal/cmd/repo/setdefault.go +++ b/internal/cmd/repo/setdefault.go @@ -14,8 +14,9 @@ import ( "github.com/spf13/cobra" "gopkg.in/yaml.v3" - "github.com/rbansal42/bb/internal/git" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/git" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // LocalConfig represents the .bb.yml file structure @@ -95,7 +96,7 @@ func runSetDefault(ctx context.Context, opts *SetDefaultOptions) error { if opts.RepoArg != "" { // Parse provided argument - workspace, repoSlug, err = parseRepoArg(opts.RepoArg) + workspace, repoSlug, err = cmdutil.ParseRepository(opts.RepoArg) if err != nil { return err } @@ -126,7 +127,7 @@ func runSetDefault(ctx context.Context, opts *SetDefaultOptions) error { fullRepo := fmt.Sprintf("%s/%s", workspace, repoSlug) // Try to validate repository exists if authenticated - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err == nil { validateCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() diff --git a/internal/cmd/repo/shared.go b/internal/cmd/repo/shared.go index 681f789..5fc3990 100644 --- a/internal/cmd/repo/shared.go +++ b/internal/cmd/repo/shared.go @@ -2,68 +2,14 @@ package repo import ( "bufio" - "encoding/json" "fmt" "io" "strings" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/config" - "github.com/rbansal42/bb/internal/git" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/config" ) -// getAPIClient creates an authenticated API client -func getAPIClient() (*api.Client, error) { - hosts, err := config.LoadHostsConfig() - if err != nil { - return nil, fmt.Errorf("failed to load hosts config: %w", err) - } - - user := hosts.GetActiveUser(config.DefaultHost) - if user == "" { - return nil, fmt.Errorf("not logged in. Run 'bb auth login' to authenticate") - } - - tokenData, _, err := config.GetTokenFromEnvOrKeyring(config.DefaultHost, user) - if err != nil { - return nil, fmt.Errorf("failed to get token: %w", err) - } - - // Try to parse as JSON (OAuth token) or use as plain token - var tokenResp struct { - AccessToken string `json:"access_token"` - } - token := tokenData - if err := json.Unmarshal([]byte(tokenData), &tokenResp); err == nil && tokenResp.AccessToken != "" { - token = tokenResp.AccessToken - } - - return api.NewClient(api.WithToken(token)), nil -} - -// parseRepository parses a repository string or detects from git remote -func parseRepository(repoFlag string) (workspace, repoSlug string, err error) { - if repoFlag != "" { - parts := strings.SplitN(repoFlag, "/", 2) - if len(parts) != 2 { - return "", "", fmt.Errorf("invalid repository format: %s (expected workspace/repo)", repoFlag) - } - // Validate both parts are non-empty - if parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("invalid repository format: %s (workspace and repo cannot be empty)", repoFlag) - } - return parts[0], parts[1], nil - } - - // Detect from git - remote, err := git.GetDefaultRemote() - if err != nil { - return "", "", fmt.Errorf("could not detect repository: %w\nUse --repo WORKSPACE/REPO to specify", err) - } - - return remote.Workspace, remote.RepoSlug, nil -} - // getCloneURL returns the appropriate clone URL based on protocol preference func getCloneURL(links api.RepositoryLinks, protocol string) string { for _, clone := range links.Clone { @@ -94,28 +40,6 @@ func getPreferredProtocol() string { return "https" } -// parseRepoArg parses a repository argument in workspace/repo format -// This is used when a repository is provided as a command argument (not a flag) -func parseRepoArg(arg string) (workspace, repoSlug string, err error) { - if arg == "" { - return "", "", fmt.Errorf("repository argument is required (workspace/repo)") - } - - parts := strings.SplitN(arg, "/", 2) - if len(parts) != 2 { - return "", "", fmt.Errorf("invalid repository format: %s (expected workspace/repo)", arg) - } - - // Validate both parts are non-empty - if parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("invalid repository format: %s (workspace and repo cannot be empty)", arg) - } - - return parts[0], parts[1], nil -} - - - // confirmDeletion prompts the user to confirm deletion by typing the repository name func confirmDeletion(repoName string, reader io.Reader) bool { scanner := bufio.NewScanner(reader) diff --git a/internal/cmd/repo/sync.go b/internal/cmd/repo/sync.go index 5a1f9b7..3a14cf9 100644 --- a/internal/cmd/repo/sync.go +++ b/internal/cmd/repo/sync.go @@ -12,9 +12,10 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/git" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/git" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type syncOptions struct { @@ -69,7 +70,7 @@ func runSync(opts *syncOptions) error { opts.repoSlug = remote.RepoSlug // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/repo/view.go b/internal/cmd/repo/view.go index 4019c67..c09378e 100644 --- a/internal/cmd/repo/view.go +++ b/internal/cmd/repo/view.go @@ -9,9 +9,10 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/browser" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/browser" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type viewOptions struct { @@ -68,13 +69,13 @@ You can specify a repository using the workspace/repo format.`, func runView(opts *viewOptions) error { // Resolve repository var err error - opts.workspace, opts.repoSlug, err = parseRepository(opts.repoArg) + opts.workspace, opts.repoSlug, err = cmdutil.ParseRepository(opts.repoArg) if err != nil { return err } // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 92b6eed..918b653 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -5,20 +5,20 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/cmd/api" - "github.com/rbansal42/bb/internal/cmd/auth" - "github.com/rbansal42/bb/internal/cmd/branch" - "github.com/rbansal42/bb/internal/cmd/browse" - "github.com/rbansal42/bb/internal/cmd/completion" - bbconfigcmd "github.com/rbansal42/bb/internal/cmd/config" - "github.com/rbansal42/bb/internal/cmd/issue" - "github.com/rbansal42/bb/internal/cmd/pipeline" - "github.com/rbansal42/bb/internal/cmd/pr" - "github.com/rbansal42/bb/internal/cmd/project" - "github.com/rbansal42/bb/internal/cmd/repo" - "github.com/rbansal42/bb/internal/cmd/snippet" - "github.com/rbansal42/bb/internal/cmd/workspace" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/cmd/api" + "github.com/rbansal42/bitbucket-cli/internal/cmd/auth" + "github.com/rbansal42/bitbucket-cli/internal/cmd/branch" + "github.com/rbansal42/bitbucket-cli/internal/cmd/browse" + "github.com/rbansal42/bitbucket-cli/internal/cmd/completion" + bbconfigcmd "github.com/rbansal42/bitbucket-cli/internal/cmd/config" + "github.com/rbansal42/bitbucket-cli/internal/cmd/issue" + "github.com/rbansal42/bitbucket-cli/internal/cmd/pipeline" + "github.com/rbansal42/bitbucket-cli/internal/cmd/pr" + "github.com/rbansal42/bitbucket-cli/internal/cmd/project" + "github.com/rbansal42/bitbucket-cli/internal/cmd/repo" + "github.com/rbansal42/bitbucket-cli/internal/cmd/snippet" + "github.com/rbansal42/bitbucket-cli/internal/cmd/workspace" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) var ( diff --git a/internal/cmd/snippet/create.go b/internal/cmd/snippet/create.go index 7a267bc..d11185a 100644 --- a/internal/cmd/snippet/create.go +++ b/internal/cmd/snippet/create.go @@ -11,8 +11,10 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // CreateOptions holds the options for the create command @@ -55,20 +57,30 @@ If no files are specified, reads from stdin.`, cmd.Flags().StringArrayVarP(&opts.Files, "file", "f", nil, "File to include (can be repeated)") cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output in JSON format") - cmd.MarkFlagRequired("workspace") cmd.MarkFlagRequired("title") return cmd } func runCreate(ctx context.Context, opts *CreateOptions) error { + // Fall back to default workspace if not specified + if opts.Workspace == "" { + defaultWs, err := config.GetDefaultWorkspace() + if err == nil && defaultWs != "" { + opts.Workspace = defaultWs + } + } + if opts.Workspace == "" { + return fmt.Errorf("workspace is required. Use --workspace or -w to specify, or set a default with 'bb workspace set-default'") + } + // Validate workspace - if err := parseWorkspace(opts.Workspace); err != nil { + if _, err := cmdutil.ParseWorkspace(opts.Workspace); err != nil { return err } // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/snippet/delete.go b/internal/cmd/snippet/delete.go index c74890a..9d722d2 100644 --- a/internal/cmd/snippet/delete.go +++ b/internal/cmd/snippet/delete.go @@ -10,7 +10,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // DeleteOptions holds the options for the delete command @@ -48,18 +50,27 @@ Use --force to skip the confirmation prompt.`, }, } - cmd.Flags().StringVarP(&opts.Workspace, "workspace", "w", "", "Workspace slug (required)") + cmd.Flags().StringVarP(&opts.Workspace, "workspace", "w", "", "Workspace slug") cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt") cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output in JSON format") - cmd.MarkFlagRequired("workspace") - return cmd } func runDelete(ctx context.Context, opts *DeleteOptions) error { + // Fall back to default workspace if not specified + if opts.Workspace == "" { + defaultWs, err := config.GetDefaultWorkspace() + if err == nil && defaultWs != "" { + opts.Workspace = defaultWs + } + } + if opts.Workspace == "" { + return fmt.Errorf("workspace is required. Use --workspace or -w to specify, or set a default with 'bb workspace set-default'") + } + // Validate workspace - if err := parseWorkspace(opts.Workspace); err != nil { + if _, err := cmdutil.ParseWorkspace(opts.Workspace); err != nil { return err } @@ -86,7 +97,7 @@ func runDelete(ctx context.Context, opts *DeleteOptions) error { } // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/snippet/edit.go b/internal/cmd/snippet/edit.go index e4774b8..519bffb 100644 --- a/internal/cmd/snippet/edit.go +++ b/internal/cmd/snippet/edit.go @@ -10,8 +10,10 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // EditOptions holds the options for the edit command @@ -51,19 +53,28 @@ You can update the title and/or add/update files.`, }, } - cmd.Flags().StringVarP(&opts.Workspace, "workspace", "w", "", "Workspace slug (required)") + cmd.Flags().StringVarP(&opts.Workspace, "workspace", "w", "", "Workspace slug") cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "New snippet title") cmd.Flags().StringArrayVarP(&opts.Files, "file", "f", nil, "File to update (can be repeated)") cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output in JSON format") - cmd.MarkFlagRequired("workspace") - return cmd } func runEdit(ctx context.Context, opts *EditOptions) error { + // Fall back to default workspace if not specified + if opts.Workspace == "" { + defaultWs, err := config.GetDefaultWorkspace() + if err == nil && defaultWs != "" { + opts.Workspace = defaultWs + } + } + if opts.Workspace == "" { + return fmt.Errorf("workspace is required. Use --workspace or -w to specify, or set a default with 'bb workspace set-default'") + } + // Validate workspace - if err := parseWorkspace(opts.Workspace); err != nil { + if _, err := cmdutil.ParseWorkspace(opts.Workspace); err != nil { return err } @@ -73,7 +84,7 @@ func runEdit(ctx context.Context, opts *EditOptions) error { } // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/snippet/list.go b/internal/cmd/snippet/list.go index c178ea0..937f1e6 100644 --- a/internal/cmd/snippet/list.go +++ b/internal/cmd/snippet/list.go @@ -2,15 +2,16 @@ package snippet import ( "context" - "encoding/json" "fmt" "text/tabwriter" "time" "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // ListOptions holds the options for the list command @@ -51,13 +52,11 @@ Snippets are workspace-scoped and can be filtered by your role.`, }, } - cmd.Flags().StringVarP(&opts.Workspace, "workspace", "w", "", "Workspace slug (required)") + cmd.Flags().StringVarP(&opts.Workspace, "workspace", "w", "", "Workspace slug (uses default if set)") cmd.Flags().StringVar(&opts.Role, "role", "", "Filter by role: owner, contributor, member") cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 30, "Maximum number of snippets to list") cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output in JSON format") - cmd.MarkFlagRequired("workspace") - return cmd } @@ -69,8 +68,19 @@ var validRoles = map[string]bool{ } func runList(ctx context.Context, opts *ListOptions) error { + // Fall back to default workspace if not specified + if opts.Workspace == "" { + defaultWs, err := config.GetDefaultWorkspace() + if err == nil && defaultWs != "" { + opts.Workspace = defaultWs + } + } + if opts.Workspace == "" { + return fmt.Errorf("workspace is required. Use --workspace or -w to specify, or set a default with 'bb workspace set-default'") + } + // Validate workspace - if err := parseWorkspace(opts.Workspace); err != nil { + if _, err := cmdutil.ParseWorkspace(opts.Workspace); err != nil { return err } @@ -80,7 +90,7 @@ func runList(ctx context.Context, opts *ListOptions) error { } // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -129,13 +139,7 @@ func outputListJSON(streams *iostreams.IOStreams, snippets []api.Snippet) error } } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputListTable(streams *iostreams.IOStreams, snippets []api.Snippet) error { @@ -143,16 +147,12 @@ func outputListTable(streams *iostreams.IOStreams, snippets []api.Snippet) error // Print header header := "ID\tTITLE\tVISIBILITY\tUPDATED" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, snippet := range snippets { id := fmt.Sprintf("%d", snippet.ID) - title := truncateString(snippet.Title, 40) + title := cmdutil.TruncateString(snippet.Title, 40) if title == "" { title = "(untitled)" } @@ -162,53 +162,10 @@ func outputListTable(streams *iostreams.IOStreams, snippets []api.Snippet) error visibility = "private" } - updated := formatTime(snippet.UpdatedOn) + updated := cmdutil.TimeAgoFromString(snippet.UpdatedOn) fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", id, title, visibility, updated) } return w.Flush() } - -// formatTime formats an ISO 8601 timestamp to a human-readable format -func formatTime(isoTime string) string { - if isoTime == "" { - return "" - } - - t, err := time.Parse(time.RFC3339, isoTime) - if err != nil { - // Try alternative format - t, err = time.Parse("2006-01-02T15:04:05.000000-07:00", isoTime) - if err != nil { - return isoTime - } - } - - // Format as relative time or date - now := time.Now() - diff := now.Sub(t) - - switch { - case diff < time.Hour: - mins := int(diff.Minutes()) - if mins <= 1 { - return "just now" - } - return fmt.Sprintf("%dm ago", mins) - case diff < 24*time.Hour: - hours := int(diff.Hours()) - if hours == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", hours) - case diff < 7*24*time.Hour: - days := int(diff.Hours() / 24) - if days == 1 { - return "yesterday" - } - return fmt.Sprintf("%d days ago", days) - default: - return t.Format("Jan 2, 2006") - } -} diff --git a/internal/cmd/snippet/shared.go b/internal/cmd/snippet/shared.go index eb7bedf..f985b23 100644 --- a/internal/cmd/snippet/shared.go +++ b/internal/cmd/snippet/shared.go @@ -1,71 +1,7 @@ +// Package snippet provides commands for managing Bitbucket snippets. package snippet -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/config" -) - -// getAPIClient creates an authenticated API client -func getAPIClient() (*api.Client, error) { - hosts, err := config.LoadHostsConfig() - if err != nil { - return nil, fmt.Errorf("failed to load hosts config: %w", err) - } - - user := hosts.GetActiveUser(config.DefaultHost) - if user == "" { - return nil, fmt.Errorf("not logged in. Run 'bb auth login' to authenticate") - } - - tokenData, _, err := config.GetTokenFromEnvOrKeyring(config.DefaultHost, user) - if err != nil { - return nil, fmt.Errorf("failed to get token: %w", err) - } - - // Try to parse as JSON (OAuth token) or use as plain token - var tokenResp struct { - AccessToken string `json:"access_token"` - } - token := tokenData - if err := json.Unmarshal([]byte(tokenData), &tokenResp); err == nil && tokenResp.AccessToken != "" { - token = tokenResp.AccessToken - } - - return api.NewClient(api.WithToken(token)), nil -} - -// parseWorkspace validates a workspace string -func parseWorkspace(workspace string) error { - if workspace == "" { - return fmt.Errorf("workspace is required. Use --workspace/-w to specify") - } - workspace = strings.TrimSpace(workspace) - if workspace == "" { - return fmt.Errorf("workspace cannot be empty") - } - return nil -} - -// truncateString truncates a string to maxLen characters and replaces newlines -func truncateString(s string, maxLen int) string { - // Replace newlines with spaces - s = strings.ReplaceAll(s, "\n", " ") - s = strings.ReplaceAll(s, "\r", " ") - // Collapse multiple spaces - for strings.Contains(s, " ") { - s = strings.ReplaceAll(s, " ", " ") - } - s = strings.TrimSpace(s) - - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} +// This package uses shared utilities from cmdutil for: +// - cmdutil.GetAPIClient() - authenticated API client +// - cmdutil.ParseWorkspace() - workspace validation +// - cmdutil.TruncateString() - string truncation diff --git a/internal/cmd/snippet/snippet.go b/internal/cmd/snippet/snippet.go index c7a769f..a581f3d 100644 --- a/internal/cmd/snippet/snippet.go +++ b/internal/cmd/snippet/snippet.go @@ -3,7 +3,7 @@ package snippet import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdSnippet creates the snippet command and its subcommands diff --git a/internal/cmd/snippet/view.go b/internal/cmd/snippet/view.go index ff8b35b..e9afbd7 100644 --- a/internal/cmd/snippet/view.go +++ b/internal/cmd/snippet/view.go @@ -10,9 +10,11 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/browser" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/browser" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // ViewOptions holds the options for the view command @@ -53,24 +55,33 @@ By default, shows snippet metadata. Use --raw to view file contents.`, }, } - cmd.Flags().StringVarP(&opts.Workspace, "workspace", "w", "", "Workspace slug (required)") + cmd.Flags().StringVarP(&opts.Workspace, "workspace", "w", "", "Workspace slug (uses default workspace if not specified)") cmd.Flags().BoolVar(&opts.Web, "web", false, "Open snippet in browser") cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output in JSON format") cmd.Flags().BoolVar(&opts.Raw, "raw", false, "Show raw file contents") - cmd.MarkFlagRequired("workspace") - return cmd } func runView(ctx context.Context, opts *ViewOptions) error { + // Fall back to default workspace if not specified + if opts.Workspace == "" { + defaultWs, err := config.GetDefaultWorkspace() + if err == nil && defaultWs != "" { + opts.Workspace = defaultWs + } + } + if opts.Workspace == "" { + return fmt.Errorf("workspace is required. Use --workspace or -w to specify, or set a default with 'bb workspace set-default'") + } + // Validate workspace - if err := parseWorkspace(opts.Workspace); err != nil { + if _, err := cmdutil.ParseWorkspace(opts.Workspace); err != nil { return err } // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/workspace/list.go b/internal/cmd/workspace/list.go index a6b2e62..bfb0ae1 100644 --- a/internal/cmd/workspace/list.go +++ b/internal/cmd/workspace/list.go @@ -2,15 +2,15 @@ package workspace import ( "context" - "encoding/json" "fmt" "text/tabwriter" "time" "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // ListOptions holds the options for the list command @@ -59,7 +59,7 @@ You can filter by your role in the workspace (owner, collaborator, or member).`, func runList(ctx context.Context, opts *ListOptions) error { // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } @@ -110,13 +110,7 @@ func outputListJSON(streams *iostreams.IOStreams, memberships []api.WorkspaceMem } } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputListTable(streams *iostreams.IOStreams, memberships []api.WorkspaceMembership) error { @@ -124,11 +118,7 @@ func outputListTable(streams *iostreams.IOStreams, memberships []api.WorkspaceMe // Print header header := "SLUG\tNAME\tROLE" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, m := range memberships { diff --git a/internal/cmd/workspace/members.go b/internal/cmd/workspace/members.go index 23c4e74..162e9ac 100644 --- a/internal/cmd/workspace/members.go +++ b/internal/cmd/workspace/members.go @@ -9,8 +9,9 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // MembersOptions holds the options for the members command @@ -56,7 +57,7 @@ Shows the username, display name, and role of each member.`, func runMembers(ctx context.Context, opts *MembersOptions) error { // Get API client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/workspace/setdefault.go b/internal/cmd/workspace/setdefault.go new file mode 100644 index 0000000..13e4130 --- /dev/null +++ b/internal/cmd/workspace/setdefault.go @@ -0,0 +1,104 @@ +package workspace + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/config" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" +) + +type setDefaultOptions struct { + streams *iostreams.IOStreams + workspace string + unset bool +} + +// NewCmdSetDefault creates the set-default command +func NewCmdSetDefault(streams *iostreams.IOStreams) *cobra.Command { + opts := &setDefaultOptions{ + streams: streams, + } + + cmd := &cobra.Command{ + Use: "set-default [workspace]", + Short: "Set or unset the default workspace", + Long: `Set a default workspace for bb commands. + +When a default workspace is set, you don't need to specify the workspace +for commands that require one. The default workspace is stored in your +bb configuration.`, + Example: ` # Set default workspace + $ bb workspace set-default myworkspace + + # View current default workspace + $ bb workspace set-default + + # Unset default workspace + $ bb workspace set-default --unset`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.workspace = args[0] + } + return runSetDefault(opts) + }, + } + + cmd.Flags().BoolVar(&opts.unset, "unset", false, "Unset the default workspace") + + return cmd +} + +func runSetDefault(opts *setDefaultOptions) error { + // If --unset flag is provided + if opts.unset { + if err := config.SetDefaultWorkspace(""); err != nil { + return fmt.Errorf("failed to unset default workspace: %w", err) + } + opts.streams.Success("Default workspace unset") + return nil + } + + // If no workspace provided, show current default + if opts.workspace == "" { + workspace, err := config.GetDefaultWorkspace() + if err != nil { + return fmt.Errorf("failed to get default workspace: %w", err) + } + if workspace == "" { + opts.streams.Info("No default workspace set") + opts.streams.Info("Use 'bb workspace set-default <workspace>' to set one") + } else { + opts.streams.Info("Default workspace: %s", workspace) + } + return nil + } + + // Validate workspace exists by making an API call + client, err := cmdutil.GetAPIClient() + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Try to get the workspace to validate it exists + _, err = client.GetWorkspace(ctx, opts.workspace) + if err != nil { + return fmt.Errorf("workspace '%s' not found or you don't have access: %w", opts.workspace, err) + } + + // Save the default workspace + if err := config.SetDefaultWorkspace(opts.workspace); err != nil { + return fmt.Errorf("failed to set default workspace: %w", err) + } + + opts.streams.Success("Default workspace set to: %s", opts.workspace) + return nil +} diff --git a/internal/cmd/workspace/shared.go b/internal/cmd/workspace/shared.go index 23d10e5..04d7faa 100644 --- a/internal/cmd/workspace/shared.go +++ b/internal/cmd/workspace/shared.go @@ -1,38 +1,5 @@ package workspace -import ( - "encoding/json" - "fmt" - - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/config" -) - -// getAPIClient creates an authenticated API client -func getAPIClient() (*api.Client, error) { - hosts, err := config.LoadHostsConfig() - if err != nil { - return nil, fmt.Errorf("failed to load hosts config: %w", err) - } - - user := hosts.GetActiveUser(config.DefaultHost) - if user == "" { - return nil, fmt.Errorf("not logged in. Run 'bb auth login' to authenticate") - } - - tokenData, _, err := config.GetTokenFromEnvOrKeyring(config.DefaultHost, user) - if err != nil { - return nil, fmt.Errorf("failed to get token: %w", err) - } - - // Try to parse as JSON (OAuth token) or use as plain token - var tokenResp struct { - AccessToken string `json:"access_token"` - } - token := tokenData - if err := json.Unmarshal([]byte(tokenData), &tokenResp); err == nil && tokenResp.AccessToken != "" { - token = tokenResp.AccessToken - } - - return api.NewClient(api.WithToken(token)), nil -} +// This file contains shared utilities for workspace commands. +// The getAPIClient function has been moved to internal/cmdutil/client.go +// as cmdutil.GetAPIClient() for use across all command packages. diff --git a/internal/cmd/workspace/view.go b/internal/cmd/workspace/view.go index 4276f3f..c8012e0 100644 --- a/internal/cmd/workspace/view.go +++ b/internal/cmd/workspace/view.go @@ -8,9 +8,10 @@ import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/api" - "github.com/rbansal42/bb/internal/browser" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/browser" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) type viewOptions struct { @@ -56,7 +57,7 @@ and the browser URL.`, func runView(ctx context.Context, opts *viewOptions) error { // Get authenticated client - client, err := getAPIClient() + client, err := cmdutil.GetAPIClient() if err != nil { return err } diff --git a/internal/cmd/workspace/workspace.go b/internal/cmd/workspace/workspace.go index bd1b88a..01d4dee 100644 --- a/internal/cmd/workspace/workspace.go +++ b/internal/cmd/workspace/workspace.go @@ -3,7 +3,7 @@ package workspace import ( "github.com/spf13/cobra" - "github.com/rbansal42/bb/internal/iostreams" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) // NewCmdWorkspace creates the workspace command and its subcommands @@ -29,6 +29,7 @@ your team. Each workspace can contain multiple repositories and projects.`, cmd.AddCommand(NewCmdList(streams)) cmd.AddCommand(NewCmdView(streams)) cmd.AddCommand(NewCmdMembers(streams)) + cmd.AddCommand(NewCmdSetDefault(streams)) return cmd } diff --git a/internal/cmdutil/client.go b/internal/cmdutil/client.go new file mode 100644 index 0000000..abf650c --- /dev/null +++ b/internal/cmdutil/client.go @@ -0,0 +1,51 @@ +// Package cmdutil provides shared utilities for command implementations. +package cmdutil + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/config" +) + +// GetAPIClient creates an authenticated API client. +// This is the canonical implementation used by all commands. +func GetAPIClient() (*api.Client, error) { + hosts, err := config.LoadHostsConfig() + if err != nil { + return nil, fmt.Errorf("failed to load hosts config: %w", err) + } + + user := hosts.GetActiveUser(config.DefaultHost) + if user == "" { + return nil, fmt.Errorf("not logged in. Run 'bb auth login' to authenticate") + } + + tokenData, _, err := config.GetTokenFromEnvOrKeyring(config.DefaultHost, user) + if err != nil { + return nil, fmt.Errorf("failed to get token: %w", err) + } + + // Check if this is Basic Auth credentials (prefixed with "basic:") + if strings.HasPrefix(tokenData, "basic:") { + credentials := strings.TrimPrefix(tokenData, "basic:") + parts := strings.SplitN(credentials, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid stored credentials format") + } + return api.NewClient(api.WithBasicAuth(parts[0], parts[1])), nil + } + + // Try to parse as JSON (OAuth token) or use as plain token (Bearer) + var tokenResp struct { + AccessToken string `json:"access_token"` + } + token := tokenData + if err := json.Unmarshal([]byte(tokenData), &tokenResp); err == nil && tokenResp.AccessToken != "" { + token = tokenResp.AccessToken + } + + return api.NewClient(api.WithToken(token)), nil +} diff --git a/internal/cmdutil/output.go b/internal/cmdutil/output.go new file mode 100644 index 0000000..9d9c5c5 --- /dev/null +++ b/internal/cmdutil/output.go @@ -0,0 +1,42 @@ +package cmdutil + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/rbansal42/bitbucket-cli/internal/iostreams" +) + +// PrintJSON marshals v as indented JSON and writes it to streams.Out. +func PrintJSON(streams *iostreams.IOStreams, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Fprintln(streams.Out, string(data)) + return nil +} + +// PrintTableHeader writes a bold header line to a tabwriter if color is enabled, +// otherwise writes a plain header. +func PrintTableHeader(streams *iostreams.IOStreams, w *tabwriter.Writer, header string) { + if streams.ColorEnabled() { + fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) + } else { + fmt.Fprintln(w, header) + } +} + +// ConfirmPrompt reads a line from reader and returns true if user typed y/yes. +func ConfirmPrompt(reader io.Reader) bool { + scanner := bufio.NewScanner(reader) + if scanner.Scan() { + input := strings.TrimSpace(strings.ToLower(scanner.Text())) + return input == "y" || input == "yes" + } + return false +} diff --git a/internal/cmdutil/repository.go b/internal/cmdutil/repository.go new file mode 100644 index 0000000..8a25de5 --- /dev/null +++ b/internal/cmdutil/repository.go @@ -0,0 +1,42 @@ +package cmdutil + +import ( + "fmt" + "strings" + + "github.com/rbansal42/bitbucket-cli/internal/git" +) + +// ParseRepository parses a repository string in WORKSPACE/REPO format, +// or detects the repository from the current git remote if not specified. +func ParseRepository(repoFlag string) (workspace, repoSlug string, err error) { + if repoFlag != "" { + parts := strings.SplitN(repoFlag, "/", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid repository format: %s (expected workspace/repo)", repoFlag) + } + // Validate both parts are non-empty + if parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("invalid repository format: %s (workspace and repo cannot be empty)", repoFlag) + } + return parts[0], parts[1], nil + } + + // Detect from git + remote, err := git.GetDefaultRemote() + if err != nil { + return "", "", fmt.Errorf("could not detect repository: %w\nUse --repo WORKSPACE/REPO to specify", err) + } + + return remote.Workspace, remote.RepoSlug, nil +} + +// ParseWorkspace validates a workspace string. +// Returns the trimmed workspace or an error if empty. +func ParseWorkspace(workspace string) (string, error) { + workspace = strings.TrimSpace(workspace) + if workspace == "" { + return "", fmt.Errorf("workspace is required. Use --workspace/-w to specify") + } + return workspace, nil +} diff --git a/internal/cmdutil/strings.go b/internal/cmdutil/strings.go new file mode 100644 index 0000000..efc9971 --- /dev/null +++ b/internal/cmdutil/strings.go @@ -0,0 +1,24 @@ +package cmdutil + +import "strings" + +// TruncateString truncates a string to maxLen characters, replacing newlines +// with spaces and adding "..." if truncated. +func TruncateString(s string, maxLen int) string { + // Replace newlines with spaces + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + // Collapse multiple spaces + for strings.Contains(s, " ") { + s = strings.ReplaceAll(s, " ", " ") + } + s = strings.TrimSpace(s) + + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." +} diff --git a/internal/cmdutil/time.go b/internal/cmdutil/time.go new file mode 100644 index 0000000..c05fb7a --- /dev/null +++ b/internal/cmdutil/time.go @@ -0,0 +1,74 @@ +package cmdutil + +import ( + "fmt" + "time" +) + +// TimeAgo returns a human-readable relative time string for a time.Time value. +// Returns "-" for zero time values. +func TimeAgo(t time.Time) string { + if t.IsZero() { + return "-" + } + + duration := time.Since(t) + + // Guard against future timestamps (clock skew, test data) + if duration < 0 { + return "in the future" + } + + switch { + case duration < time.Minute: + return "just now" + case duration < time.Hour: + mins := int(duration.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + case duration < 24*time.Hour: + hours := int(duration.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + case duration < 30*24*time.Hour: + days := int(duration.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + case duration < 365*24*time.Hour: + months := int(duration.Hours() / 24 / 30) + if months == 1 { + return "1 month ago" + } + return fmt.Sprintf("%d months ago", months) + default: + years := int(duration.Hours() / 24 / 365) + if years == 1 { + return "1 year ago" + } + return fmt.Sprintf("%d years ago", years) + } +} + +// TimeAgoFromString parses an ISO 8601 / RFC3339 timestamp string and returns +// a human-readable relative time. Returns the raw string on parse failure. +func TimeAgoFromString(isoTime string) string { + if isoTime == "" { + return "-" + } + + t, err := time.Parse(time.RFC3339, isoTime) + if err != nil { + t, err = time.Parse("2006-01-02T15:04:05.000000-07:00", isoTime) + if err != nil { + return isoTime + } + } + + return TimeAgo(t) +} diff --git a/internal/cmdutil/user.go b/internal/cmdutil/user.go new file mode 100644 index 0000000..444cd08 --- /dev/null +++ b/internal/cmdutil/user.go @@ -0,0 +1,21 @@ +package cmdutil + +import "github.com/rbansal42/bitbucket-cli/internal/api" + +// GetUserDisplayName returns the best available display name for a user. +// Returns "-" if user is nil, falls back through Username → Nickname → "unknown". +func GetUserDisplayName(user *api.User) string { + if user == nil { + return "-" + } + if user.DisplayName != "" { + return user.DisplayName + } + if user.Username != "" { + return user.Username + } + if user.Nickname != "" { + return user.Nickname + } + return "unknown" +} diff --git a/internal/config/config.go b/internal/config/config.go index 25dd14b..131a960 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,12 +21,13 @@ const ( // Config represents the main configuration type Config struct { - GitProtocol string `yaml:"git_protocol,omitempty"` - Editor string `yaml:"editor,omitempty"` - Prompt string `yaml:"prompt,omitempty"` - Pager string `yaml:"pager,omitempty"` - Browser string `yaml:"browser,omitempty"` - HTTPTimeout int `yaml:"http_timeout,omitempty"` + GitProtocol string `yaml:"git_protocol,omitempty"` + Editor string `yaml:"editor,omitempty"` + Prompt string `yaml:"prompt,omitempty"` + Pager string `yaml:"pager,omitempty"` + Browser string `yaml:"browser,omitempty"` + HTTPTimeout int `yaml:"http_timeout,omitempty"` + DefaultWorkspace string `yaml:"default_workspace,omitempty"` } // HostConfig represents per-host configuration @@ -228,3 +229,22 @@ func (h HostsConfig) AuthenticatedHosts() []string { } return hosts } + +// GetDefaultWorkspace returns the default workspace from config +func GetDefaultWorkspace() (string, error) { + config, err := LoadConfig() + if err != nil { + return "", err + } + return config.DefaultWorkspace, nil +} + +// SetDefaultWorkspace sets the default workspace in config +func SetDefaultWorkspace(workspace string) error { + config, err := LoadConfig() + if err != nil { + return err + } + config.DefaultWorkspace = workspace + return SaveConfig(config) +}