diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..2f500a6 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,86 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: coverage.out + continue-on-error: true + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + + build: + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux, darwin] + goarch: [amd64, arm64] + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + go build -ldflags "-X main.version=${{ github.sha }}" -o jcli-${{ matrix.goos }}-${{ matrix.goarch }} . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: jcli-${{ matrix.goos }}-${{ matrix.goarch }} + path: jcli-${{ matrix.goos }}-${{ matrix.goarch }} + + integration: + runs-on: ubuntu-latest + needs: [test, build] + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Run integration tests + run: go test -v -tags=integration ./tests/integration/... diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..8cbdff3 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,101 @@ +name: Release + +on: + push: + branches: [master] + +permissions: + contents: write + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Run tests + run: go test -v -race ./... + + - name: Run integration tests + run: go test -v -tags=integration ./tests/integration/... + + release: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Calculate next version + id: version + run: | + # Get the latest tag + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Latest tag: $LATEST_TAG" + + # Extract version numbers + VERSION=${LATEST_TAG#v} + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + + # Get commit messages since last tag + if [ "$LATEST_TAG" = "v0.0.0" ]; then + COMMITS=$(git log --pretty=format:"%s" HEAD) + else + COMMITS=$(git log --pretty=format:"%s" ${LATEST_TAG}..HEAD) + fi + + # Determine version bump based on conventional commits + if echo "$COMMITS" | grep -qE "^BREAKING CHANGE:|^[a-z]+!:"; then + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + elif echo "$COMMITS" | grep -qE "^feat(\(.+\))?:"; then + MINOR=$((MINOR + 1)) + PATCH=0 + else + PATCH=$((PATCH + 1)) + fi + + NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}" + echo "New version: $NEW_VERSION" + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + + # Check if this version already exists + if git tag | grep -q "^${NEW_VERSION}$"; then + echo "Tag $NEW_VERSION already exists, skipping release" + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + + - name: Run GoReleaser + if: steps.version.outputs.skip != 'true' + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }} + + - name: Create and push tag + if: steps.version.outputs.skip != 'true' + run: | + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + git tag ${{ steps.version.outputs.version }} + git push origin ${{ steps.version.outputs.version }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d5906d --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Binaries +jcli +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of go coverage tool +*.out +coverage.html + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Build output +dist/ + +# OS files +.DS_Store +Thumbs.db diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..24e04cd --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,50 @@ +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - format: tar.gz + name_template: >- + {{ .ProjectName }}_ + {{- .Version }}_ + {{- .Os }}_ + {{- .Arch }} + format_overrides: + - goos: windows + format: zip + +checksum: + name_template: 'checksums.txt' + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + +release: + github: + owner: tutunak + name: jcli + draft: false + prerelease: auto + name_template: "{{.Tag}}" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..897577c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build Commands + +```bash +make build # Build binary with version from git tags +make test # Run tests with race detection and coverage +make lint # Run golangci-lint +make test-integration # Run integration tests (requires Jira credentials) +go test ./internal/branch/... # Run tests for a specific package +``` + +## Architecture + +jcli is a CLI tool for Jira workflow management. It uses manual argument parsing (no CLI framework). + +### Package Structure + +- **cmd/** - Command handlers. `root.go` routes to subcommands via switch statement on `os.Args[1]`. Each command file (e.g., `issue_select.go`) contains one handler function. + +- **internal/jira/** - Jira API client using REST API v3. `Client` interface allows mocking. `HTTPClient` implements actual API calls with Basic Auth. Note: descriptions use `json.RawMessage` because Jira v3 returns ADF format, not strings. + +- **internal/config/** - YAML config at `~/.config/jcli/config.yaml`. Supports `JIRA_API_TOKEN` env var override. + +- **internal/state/** - JSON state at `~/.local/state/jcli/state.json`. Tracks currently selected issue. + +- **internal/branch/** - Branch name generator. Format: `--`. Issue key preserves original case; summary is lowercased. + +- **internal/tui/** - Interactive issue selector using `github.com/charmbracelet/huh`. + +### Key Design Decisions + +- No CLI framework (cobra, urfave/cli) - uses manual `os.Args` parsing +- `Client` interface in jira package enables `MockClient` for testing +- XDG Base Directory paths for config and state +- JQL query includes `assignee = currentUser()` to show only user's assigned issues diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..992f6a0 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: build test lint clean install all + +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +LDFLAGS := -ldflags "-X main.version=$(VERSION)" + +all: lint test build + +build: + go build $(LDFLAGS) -o jcli . + +build-all: + GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o dist/jcli-linux-amd64 . + GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o dist/jcli-linux-arm64 . + GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o dist/jcli-darwin-amd64 . + GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o dist/jcli-darwin-arm64 . + +test: + go test -v -race -coverprofile=coverage.out ./... + +test-integration: + go test -v -tags=integration ./tests/integration/... + +lint: + golangci-lint run ./... + +clean: + rm -f jcli coverage.out + rm -rf dist/ + +install: build + cp jcli $(GOPATH)/bin/ + +coverage: test + go tool cover -html=coverage.out -o coverage.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a02bef --- /dev/null +++ b/README.md @@ -0,0 +1,226 @@ +# jcli + +A command-line tool for Jira workflow management. Quickly select issues, track your current work, and generate consistent branch names. + +## Features + +- **Interactive issue selection** - Browse and select from your assigned "In Progress" issues +- **Direct issue selection** - Select any issue by its key +- **Current issue tracking** - Keep track of what you're working on +- **Branch name generation** - Generate consistent, readable branch names from issue keys and summaries +- **XDG-compliant configuration** - Config stored in `~/.config/jcli/` + +## Installation + +### From source + +```bash +git clone https://github.com/dk/jcli.git +cd jcli +go build -o jcli . +sudo mv jcli /usr/local/bin/ +``` + +### Using Go + +```bash +go install github.com/dk/jcli@latest +``` + +## Configuration + +jcli uses a YAML configuration file located at `~/.config/jcli/config.yaml`. + +### Initial Setup + +1. **Set your Jira credentials:** + +```bash +jcli config credentials +``` + +This will interactively prompt for: +- Jira URL (e.g., `https://yourcompany.atlassian.net`) +- Email address +- API token + +2. **Set your default project:** + +```bash +jcli config project YOUR_PROJECT_KEY +``` + +3. **Optionally, change the default status filter:** + +```bash +jcli config status "In Progress" +``` + +### Getting a Jira API Token + +1. Go to +2. Click "Create API token" +3. Give it a name (e.g., "jcli") +4. Copy the generated token + +### Configuration File Format + +```yaml +jira: + url: https://yourcompany.atlassian.net + email: your.email@company.com + api_token: your_api_token_here + +defaults: + project: PROJ + status: In Progress +``` + +### Environment Variables + +You can override the API token using an environment variable: + +```bash +export JIRA_API_TOKEN=your_api_token_here +``` + +## Usage + +### Select an Issue + +**Interactive selection** - Shows your assigned issues with "In Progress" status: + +```bash +jcli issue select +``` + +Use arrow keys to navigate and Enter to select. + +**Direct selection** - Select a specific issue by key: + +```bash +jcli issue select PROJ-123 +``` + +### View Current Issue + +Display the currently selected issue: + +```bash +jcli issue current +``` + +Output: +```text +Current issue: PROJ-123 +Summary: Implement user authentication +Selected at: 2024-01-15 10:30:00 +``` + +### Generate Branch Name + +Generate a branch name for the current issue: + +```bash +jcli issue branch +``` + +Output: +``` +PROJ-123-implement-user-authentication-847291 +``` + +The branch name format is: `--` + +- Issue key is preserved in original case (uppercase) +- Summary is converted to lowercase +- Special characters are replaced with hyphens +- Multiple hyphens are collapsed +- Long summaries are truncated at 50 characters +- Random number (0-999999) ensures uniqueness + +### Create a Git Branch + +Combine with git to create and checkout a new branch: + +```bash +git checkout -b $(jcli issue branch) +``` + +## Commands Reference + +### Root Commands + +| Command | Description | +|----------------|---------------------------| +| `jcli help` | Show help message | +| `jcli version` | Print version information | + +### Issue Commands + +| Command | Description | +|---------------------------|----------------------------------------------------------| +| `jcli issue select` | Interactive selection from assigned "In Progress" issues | +| `jcli issue select ` | Select a specific issue by key | +| `jcli issue current` | Show currently selected issue | +| `jcli issue branch` | Generate branch name for current issue | + +### Config Commands + +| Command | Description | +|-----------------------------|------------------------------------| +| `jcli config credentials` | Set Jira credentials interactively | +| `jcli config project ` | Set default project key | +| `jcli config status ` | Set default status filter | + +## Workflow Example + +```bash +# One-time setup +jcli config credentials +jcli config project MYPROJ + +# Daily workflow +jcli issue select # Pick an issue to work on +jcli issue current # Verify selection +git checkout -b $(jcli issue branch) # Create feature branch + +# Or select and branch in one go +jcli issue select MYPROJ-456 +git checkout -b $(jcli issue branch) +``` + +## File Locations + +| File | Location | Purpose | +|--------|----------------------------------|-------------------------------| +| Config | `~/.config/jcli/config.yaml` | Jira credentials and defaults | +| State | `~/.local/state/jcli/state.json` | Current issue tracking | + +## Development + +### Prerequisites + +- Go 1.21 or later + +### Building + +```bash +make build +``` + +### Testing + +```bash +make test +``` + +### Linting + +```bash +make lint +``` + +## License + +MIT diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..a62a091 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/tutunak/jcli/internal/config" + "github.com/tutunak/jcli/internal/tui" +) + +func executeConfig(args []string) error { + if len(args) == 0 { + printConfigUsage() + return nil + } + + switch args[0] { + case "project": + return executeConfigProject(args[1:]) + case "status": + return executeConfigStatus(args[1:]) + case "credentials": + return executeConfigCredentials(args[1:]) + case "show": + return executeConfigShow() + case "help", "--help", "-h": + printConfigUsage() + return nil + default: + fmt.Fprintf(os.Stderr, "Unknown config command: %s\n", args[0]) + printConfigUsage() + return fmt.Errorf("unknown config command: %s", args[0]) + } +} + +func printConfigUsage() { + fmt.Println(`jcli config - Configure jcli settings + +Usage: + jcli config [value] + +Commands: + project Set default Jira project + status Set default status filter (default: "In Progress") + credentials Set Jira credentials interactively + show Show current configuration + +Examples: + jcli config project MYPROJ + jcli config status "To Do" + jcli config credentials + jcli config show`) +} + +func executeConfigProject(args []string) error { + if len(args) == 0 { + return fmt.Errorf("project key required") + } + + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + cfg.Defaults.Project = args[0] + + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("Default project set to: %s\n", args[0]) + return nil +} + +func executeConfigStatus(args []string) error { + if len(args) == 0 { + return fmt.Errorf("status name required") + } + + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + cfg.Defaults.Status = args[0] + + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("Default status filter set to: %s\n", args[0]) + return nil +} + +func executeConfigCredentials(args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + selector := tui.NewSelector() + url, email, token, err := selector.PromptCredentials() + if err != nil { + return err + } + + cfg.Jira.URL = url + cfg.Jira.Email = email + cfg.Jira.APIToken = token + + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println("Credentials saved successfully.") + fmt.Println("Note: API token is stored in the config file. You can also use JIRA_API_TOKEN environment variable.") + return nil +} + +func executeConfigShow() error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + configPath, _ := config.ConfigPath() + + fmt.Println("Configuration:") + fmt.Printf(" Config file: %s\n", configPath) + fmt.Println() + fmt.Println("Jira:") + fmt.Printf(" URL: %s\n", maskEmpty(cfg.Jira.URL)) + fmt.Printf(" Email: %s\n", maskEmpty(cfg.Jira.Email)) + fmt.Printf(" API Token: %s\n", maskSecret(cfg.Jira.APIToken)) + fmt.Println() + fmt.Println("Defaults:") + fmt.Printf(" Project: %s\n", maskEmpty(cfg.Defaults.Project)) + fmt.Printf(" Status: %s\n", cfg.Defaults.Status) + + return nil +} + +func maskEmpty(s string) string { + if s == "" { + return "(not set)" + } + return s +} + +func maskSecret(s string) string { + if s == "" { + return "(not set)" + } + if len(s) <= 4 { + return "****" + } + return s[:4] + "****" +} diff --git a/cmd/issue.go b/cmd/issue.go new file mode 100644 index 0000000..bd6c1b9 --- /dev/null +++ b/cmd/issue.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "fmt" + "os" +) + +func executeIssue(args []string) error { + if len(args) == 0 { + printIssueUsage() + return nil + } + + switch args[0] { + case "select": + return executeIssueSelect(args[1:]) + case "current": + return executeIssueCurrent(args[1:]) + case "branch": + return executeIssueBranch(args[1:]) + case "help", "--help", "-h": + printIssueUsage() + return nil + default: + fmt.Fprintf(os.Stderr, "Unknown issue command: %s\n", args[0]) + printIssueUsage() + return fmt.Errorf("unknown issue command: %s", args[0]) + } +} + +func printIssueUsage() { + fmt.Println(`jcli issue - Manage Jira issues + +Usage: + jcli issue [flags] + +Commands: + select [issue-id] Select an issue (interactive or by ID) + current Show current active issue + branch Generate branch name for current issue + +Examples: + jcli issue select # Interactive selection from In Progress issues + jcli issue select PROJ-123 # Select specific issue + jcli issue current # Show currently selected issue + jcli issue branch # Generate branch name for current issue`) +} diff --git a/cmd/issue_branch.go b/cmd/issue_branch.go new file mode 100644 index 0000000..e98a776 --- /dev/null +++ b/cmd/issue_branch.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "fmt" + + "github.com/tutunak/jcli/internal/branch" + "github.com/tutunak/jcli/internal/state" +) + +func executeIssueBranch(args []string) error { + st, err := state.Load() + if err != nil { + return fmt.Errorf("failed to load state: %w", err) + } + + if !st.HasCurrentIssue() { + fmt.Println("No issue currently selected.") + fmt.Println("Use 'jcli issue select' to select an issue first.") + return fmt.Errorf("no issue selected") + } + + issue := st.CurrentIssue + gen := branch.NewGenerator() + branchName := gen.Generate(issue.Key, issue.Summary) + + fmt.Println(branchName) + return nil +} diff --git a/cmd/issue_current.go b/cmd/issue_current.go new file mode 100644 index 0000000..db969b9 --- /dev/null +++ b/cmd/issue_current.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "fmt" + + "github.com/tutunak/jcli/internal/state" +) + +func executeIssueCurrent(args []string) error { + st, err := state.Load() + if err != nil { + return fmt.Errorf("failed to load state: %w", err) + } + + if !st.HasCurrentIssue() { + fmt.Println("No issue currently selected.") + fmt.Println("Use 'jcli issue select' to select an issue.") + return nil + } + + issue := st.CurrentIssue + fmt.Printf("Current issue: %s\n", issue.Key) + fmt.Printf("Summary: %s\n", issue.Summary) + fmt.Printf("Selected at: %s\n", issue.SelectedAt.Format("2006-01-02 15:04:05")) + + return nil +} diff --git a/cmd/issue_select.go b/cmd/issue_select.go new file mode 100644 index 0000000..c692047 --- /dev/null +++ b/cmd/issue_select.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/tutunak/jcli/internal/config" + "github.com/tutunak/jcli/internal/jira" + "github.com/tutunak/jcli/internal/state" + "github.com/tutunak/jcli/internal/tui" +) + +func executeIssueSelect(args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if err := cfg.Validate(); err != nil { + fmt.Fprintln(os.Stderr, "Configuration error:", err) + fmt.Fprintln(os.Stderr, "Run 'jcli config credentials' to set up your Jira credentials.") + return err + } + + if !cfg.HasProject() { + fmt.Fprintln(os.Stderr, "Warning: No default project set. Run 'jcli config project ' to set one.") + return fmt.Errorf("no project configured") + } + + client := jira.NewClient(cfg.Jira.URL, cfg.Jira.Email, cfg.Jira.APIToken) + st, err := state.Load() + if err != nil { + return fmt.Errorf("failed to load state: %w", err) + } + + // If issue ID provided, select it directly + if len(args) > 0 { + issueKey := args[0] + return selectIssueByKey(client, st, issueKey) + } + + // Interactive selection + return selectIssueInteractive(client, st, cfg) +} + +func selectIssueByKey(client jira.Client, st *state.State, issueKey string) error { + issue, err := client.GetIssue(issueKey) + if err != nil { + return fmt.Errorf("failed to get issue %s: %w", issueKey, err) + } + + st.SetCurrentIssue(issue.Key, issue.Fields.Summary) + if err := st.Save(); err != nil { + return fmt.Errorf("failed to save state: %w", err) + } + + fmt.Printf("Selected: %s - %s\n", issue.Key, issue.Fields.Summary) + return nil +} + +func selectIssueInteractive(client jira.Client, st *state.State, cfg *config.Config) error { + issues, err := client.SearchIssues(cfg.Defaults.Project, cfg.Defaults.Status) + if err != nil { + return fmt.Errorf("failed to search issues: %w", err) + } + + if len(issues) == 0 { + fmt.Printf("No issues found in project %s with status %q\n", cfg.Defaults.Project, cfg.Defaults.Status) + return nil + } + + selector := tui.NewSelector() + selected, err := selector.SelectIssue(issues) + if err != nil { + return err + } + + st.SetCurrentIssue(selected.Key, selected.Fields.Summary) + if err := st.Save(); err != nil { + return fmt.Errorf("failed to save state: %w", err) + } + + fmt.Printf("Selected: %s - %s\n", selected.Key, selected.Fields.Summary) + return nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..c64b2e5 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + "os" +) + +var version = "dev" + +func SetVersion(v string) { + version = v +} + +func Execute() error { + if len(os.Args) < 2 { + printUsage() + return nil + } + + switch os.Args[1] { + case "version", "--version", "-v": + fmt.Printf("jcli version %s\n", version) + return nil + case "help", "--help", "-h": + printUsage() + return nil + case "issue": + return executeIssue(os.Args[2:]) + case "config": + return executeConfig(os.Args[2:]) + default: + fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1]) + printUsage() + return fmt.Errorf("unknown command: %s", os.Args[1]) + } +} + +func printUsage() { + fmt.Println(`jcli - Jira CLI workflow management tool + +Usage: + jcli [subcommand] [flags] + +Commands: + issue Manage Jira issues + config Configure jcli settings + version Print version information + help Show this help message + +Issue Commands: + jcli issue select [issue-id] Select an issue (interactive or by ID) + jcli issue current Show current active issue + jcli issue branch Generate branch name for current issue + +Config Commands: + jcli config project Set default project + jcli config status Set default status filter + jcli config credentials Set Jira credentials (interactive) + +Use "jcli --help" for more information about a command.`) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8bfea80 --- /dev/null +++ b/go.mod @@ -0,0 +1,37 @@ +module github.com/tutunak/jcli + +go 1.24.12 + +require ( + github.com/charmbracelet/huh v0.8.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..eadd8a0 --- /dev/null +++ b/go.sum @@ -0,0 +1,79 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/branch/generator.go b/internal/branch/generator.go new file mode 100644 index 0000000..2027f8d --- /dev/null +++ b/internal/branch/generator.go @@ -0,0 +1,126 @@ +package branch + +import ( + "math/rand" + "regexp" + "strings" + "unicode" +) + +var ( + nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]+`) + multipleHyphens = regexp.MustCompile(`-+`) +) + +type Generator struct { + randFunc func() int +} + +func NewGenerator() *Generator { + return &Generator{ + randFunc: func() int { + return rand.Intn(1000000) + }, + } +} + +func NewGeneratorWithRand(randFunc func() int) *Generator { + return &Generator{ + randFunc: randFunc, + } +} + +func (g *Generator) Generate(issueKey, summary string) string { + normalized := normalizeSummary(summary) + randomNum := g.randFunc() + return issueKey + "-" + normalized + "-" + formatNumber(randomNum) +} + +func normalizeSummary(summary string) string { + // Convert to lowercase + s := strings.ToLower(summary) + + // Replace common unicode characters with ASCII equivalents + s = replaceUnicode(s) + + // Replace non-alphanumeric characters with hyphens + s = nonAlphanumeric.ReplaceAllString(s, "-") + + // Collapse multiple hyphens + s = multipleHyphens.ReplaceAllString(s, "-") + + // Trim leading and trailing hyphens + s = strings.Trim(s, "-") + + // Limit length to prevent overly long branch names + if len(s) > 50 { + s = s[:50] + // Avoid cutting in the middle of a word + if lastHyphen := strings.LastIndex(s, "-"); lastHyphen > 30 { + s = s[:lastHyphen] + } + s = strings.Trim(s, "-") + } + + return s +} + +func replaceUnicode(s string) string { + var result strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z': + result.WriteRune(r) + case r >= '0' && r <= '9': + result.WriteRune(r) + case unicode.IsLetter(r): + // Try to convert accented characters + switch r { + case 'à', 'á', 'â', 'ã', 'ä', 'å': + result.WriteRune('a') + case 'è', 'é', 'ê', 'ë': + result.WriteRune('e') + case 'ì', 'í', 'î', 'ï': + result.WriteRune('i') + case 'ò', 'ó', 'ô', 'õ', 'ö': + result.WriteRune('o') + case 'ù', 'ú', 'û', 'ü': + result.WriteRune('u') + case 'ñ': + result.WriteRune('n') + case 'ç': + result.WriteRune('c') + default: + result.WriteRune('-') + } + default: + result.WriteRune('-') + } + } + return result.String() +} + +func formatNumber(n int) string { + return strings.TrimPrefix(strings.TrimPrefix( + strings.TrimPrefix(strings.TrimPrefix( + strings.TrimPrefix(padNumber(n), "0"), "0"), "0"), "0"), "0") +} + +func padNumber(n int) string { + s := "000000" + num := n % 1000000 + result := s + itoa(num) + return result[len(result)-6:] +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var digits []byte + for n > 0 { + digits = append([]byte{byte('0' + n%10)}, digits...) + n /= 10 + } + return string(digits) +} diff --git a/internal/branch/generator_test.go b/internal/branch/generator_test.go new file mode 100644 index 0000000..85d369d --- /dev/null +++ b/internal/branch/generator_test.go @@ -0,0 +1,148 @@ +package branch + +import ( + "strings" + "testing" +) + +func TestGenerator_Generate(t *testing.T) { + // Use fixed random for predictable tests + gen := NewGeneratorWithRand(func() int { return 847291 }) + + tests := []struct { + name string + issueKey string + summary string + want string + }{ + { + name: "simple summary", + issueKey: "PROJ-123", + summary: "Add user authentication", + want: "PROJ-123-add-user-authentication-847291", + }, + { + name: "summary with special characters", + issueKey: "TEST-456", + summary: "Fix bug: user can't login (urgent!)", + want: "TEST-456-fix-bug-user-can-t-login-urgent-847291", + }, + { + name: "summary with numbers", + issueKey: "DEV-789", + summary: "Upgrade to v2.0", + want: "DEV-789-upgrade-to-v2-0-847291", + }, + { + name: "summary with extra spaces", + issueKey: "TASK-1", + summary: " Multiple spaces here ", + want: "TASK-1-multiple-spaces-here-847291", + }, + { + name: "empty summary", + issueKey: "PROJ-999", + summary: "", + want: "PROJ-999--847291", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := gen.Generate(tt.issueKey, tt.summary) + if got != tt.want { + t.Errorf("Generate() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGenerator_GenerateWithRandomNumber(t *testing.T) { + gen := NewGenerator() + branch := gen.Generate("TEST-1", "Test") + + // Should start with issue key preserving original case + if !strings.HasPrefix(branch, "TEST-1-") { + t.Errorf("branch should start with 'TEST-1-', got %q", branch) + } + + // Should end with a number + parts := strings.Split(branch, "-") + lastPart := parts[len(parts)-1] + for _, c := range lastPart { + if c < '0' || c > '9' { + t.Errorf("last part should be numeric, got %q", lastPart) + break + } + } +} + +func TestNormalizeSummary(t *testing.T) { + tests := []struct { + name string + summary string + want string + }{ + { + name: "lowercase conversion", + summary: "UPPERCASE Text", + want: "uppercase-text", + }, + { + name: "special characters replaced", + summary: "hello@world#test$123", + want: "hello-world-test-123", + }, + { + name: "multiple hyphens collapsed", + summary: "hello---world", + want: "hello-world", + }, + { + name: "leading and trailing hyphens trimmed", + summary: "---hello---", + want: "hello", + }, + { + name: "unicode characters", + summary: "café résumé naïve", + want: "cafe-resume-naive", + }, + { + name: "long summary truncated", + summary: "This is a very long summary that should be truncated to avoid creating branch names that are too long for most git systems", + want: "this-is-a-very-long-summary-that-should-be", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeSummary(tt.summary) + if got != tt.want { + t.Errorf("normalizeSummary(%q) = %q, want %q", tt.summary, got, tt.want) + } + }) + } +} + +func TestFormatNumber(t *testing.T) { + tests := []struct { + n int + want string + }{ + {0, "0"}, + {1, "1"}, + {123, "123"}, + {999999, "999999"}, + {1000000, "0"}, // wraps around + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := formatNumber(tt.n) + if got != tt.want { + t.Errorf("formatNumber(%d) = %q, want %q", tt.n, got, tt.want) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ba94add --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,135 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type JiraConfig struct { + URL string `yaml:"url"` + Email string `yaml:"email"` + APIToken string `yaml:"api_token"` +} + +type Defaults struct { + Project string `yaml:"project"` + Status string `yaml:"status"` +} + +type Config struct { + Jira JiraConfig `yaml:"jira"` + Defaults Defaults `yaml:"defaults"` +} + +func DefaultConfig() *Config { + return &Config{ + Defaults: Defaults{ + Status: "In Progress", + }, + } +} + +func ConfigDir() (string, error) { + if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" { + return filepath.Join(xdgConfig, "jcli"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(home, ".config", "jcli"), nil +} + +func ConfigPath() (string, error) { + dir, err := ConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.yaml"), nil +} + +func Load() (*Config, error) { + path, err := ConfigPath() + if err != nil { + return nil, err + } + + cfg := DefaultConfig() + + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + cfg.applyEnvOverrides() + return cfg, nil + } + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + cfg.applyEnvOverrides() + return cfg, nil +} + +func (c *Config) applyEnvOverrides() { + if url := os.Getenv("JIRA_URL"); url != "" { + c.Jira.URL = url + } + if email := os.Getenv("JIRA_EMAIL"); email != "" { + c.Jira.Email = email + } + if token := os.Getenv("JIRA_API_TOKEN"); token != "" { + c.Jira.APIToken = token + } + if project := os.Getenv("JIRA_PROJECT"); project != "" { + c.Defaults.Project = project + } + if status := os.Getenv("JIRA_STATUS"); status != "" { + c.Defaults.Status = status + } +} + +func (c *Config) Save() error { + path, err := ConfigPath() + if err != nil { + return err + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + data, err := yaml.Marshal(c) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +func (c *Config) Validate() error { + if c.Jira.URL == "" { + return fmt.Errorf("jira.url is not configured") + } + if c.Jira.Email == "" { + return fmt.Errorf("jira.email is not configured") + } + if c.Jira.APIToken == "" { + return fmt.Errorf("jira.api_token is not configured (set via config or JIRA_API_TOKEN env var)") + } + return nil +} + +func (c *Config) HasProject() bool { + return c.Defaults.Project != "" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e308637 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,214 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + if cfg.Defaults.Status != "In Progress" { + t.Errorf("expected default status 'In Progress', got %q", cfg.Defaults.Status) + } +} + +func TestConfigDir(t *testing.T) { + t.Run("uses XDG_CONFIG_HOME if set", func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/custom/config") + dir, err := ConfigDir() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := "/custom/config/jcli" + if dir != expected { + t.Errorf("expected %q, got %q", expected, dir) + } + }) + + t.Run("falls back to ~/.config/jcli", func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + dir, err := ConfigDir() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + home, _ := os.UserHomeDir() + expected := filepath.Join(home, ".config", "jcli") + if dir != expected { + t.Errorf("expected %q, got %q", expected, dir) + } + }) +} + +func TestLoadAndSave(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + // Load should return default config when file doesn't exist + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error loading non-existent config: %v", err) + } + if cfg.Defaults.Status != "In Progress" { + t.Errorf("expected default status, got %q", cfg.Defaults.Status) + } + + // Save and reload + cfg.Jira.URL = "https://test.atlassian.net" + cfg.Jira.Email = "test@example.com" + cfg.Jira.APIToken = "token123" + cfg.Defaults.Project = "TEST" + + if err := cfg.Save(); err != nil { + t.Fatalf("unexpected error saving config: %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("unexpected error loading config: %v", err) + } + + if loaded.Jira.URL != "https://test.atlassian.net" { + t.Errorf("expected URL to persist, got %q", loaded.Jira.URL) + } + if loaded.Defaults.Project != "TEST" { + t.Errorf("expected project to persist, got %q", loaded.Defaults.Project) + } +} + +func TestEnvOverrides(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + // Create a config file + cfg := DefaultConfig() + cfg.Jira.URL = "https://file.atlassian.net" + cfg.Jira.Email = "file@example.com" + if err := cfg.Save(); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + // Set env vars + t.Setenv("JIRA_URL", "https://env.atlassian.net") + t.Setenv("JIRA_EMAIL", "env@example.com") + t.Setenv("JIRA_API_TOKEN", "env-token") + t.Setenv("JIRA_PROJECT", "ENVPROJ") + t.Setenv("JIRA_STATUS", "Done") + + loaded, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if loaded.Jira.URL != "https://env.atlassian.net" { + t.Errorf("expected env URL override, got %q", loaded.Jira.URL) + } + if loaded.Jira.Email != "env@example.com" { + t.Errorf("expected env email override, got %q", loaded.Jira.Email) + } + if loaded.Jira.APIToken != "env-token" { + t.Errorf("expected env token override, got %q", loaded.Jira.APIToken) + } + if loaded.Defaults.Project != "ENVPROJ" { + t.Errorf("expected env project override, got %q", loaded.Defaults.Project) + } + if loaded.Defaults.Status != "Done" { + t.Errorf("expected env status override, got %q", loaded.Defaults.Status) + } +} + +func TestEnvOverridesWithoutConfigFile(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + // Set env vars without creating a config file + t.Setenv("JIRA_URL", "https://env.atlassian.net") + t.Setenv("JIRA_EMAIL", "env@example.com") + t.Setenv("JIRA_API_TOKEN", "env-token") + t.Setenv("JIRA_PROJECT", "ENVPROJ") + t.Setenv("JIRA_STATUS", "Done") + + loaded, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if loaded.Jira.URL != "https://env.atlassian.net" { + t.Errorf("expected env URL override, got %q", loaded.Jira.URL) + } + if loaded.Jira.Email != "env@example.com" { + t.Errorf("expected env email override, got %q", loaded.Jira.Email) + } + if loaded.Jira.APIToken != "env-token" { + t.Errorf("expected env token override, got %q", loaded.Jira.APIToken) + } + if loaded.Defaults.Project != "ENVPROJ" { + t.Errorf("expected env project override, got %q", loaded.Defaults.Project) + } + if loaded.Defaults.Status != "Done" { + t.Errorf("expected env status override, got %q", loaded.Defaults.Status) + } +} + +func TestValidate(t *testing.T) { + tests := []struct { + name string + cfg *Config + wantErr bool + }{ + { + name: "empty config", + cfg: &Config{}, + wantErr: true, + }, + { + name: "missing email", + cfg: &Config{ + Jira: JiraConfig{URL: "https://test.atlassian.net"}, + }, + wantErr: true, + }, + { + name: "missing token", + cfg: &Config{ + Jira: JiraConfig{ + URL: "https://test.atlassian.net", + Email: "test@example.com", + }, + }, + wantErr: true, + }, + { + name: "valid config", + cfg: &Config{ + Jira: JiraConfig{ + URL: "https://test.atlassian.net", + Email: "test@example.com", + APIToken: "token", + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestHasProject(t *testing.T) { + cfg := &Config{} + if cfg.HasProject() { + t.Error("expected HasProject() to return false for empty project") + } + + cfg.Defaults.Project = "TEST" + if !cfg.HasProject() { + t.Error("expected HasProject() to return true for set project") + } +} diff --git a/internal/jira/client.go b/internal/jira/client.go new file mode 100644 index 0000000..345a7ca --- /dev/null +++ b/internal/jira/client.go @@ -0,0 +1,116 @@ +package jira + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type Client interface { + SearchIssues(project, status string) ([]Issue, error) + GetIssue(key string) (*Issue, error) +} + +type HTTPClient struct { + baseURL string + email string + apiToken string + httpClient *http.Client +} + +func NewClient(baseURL, email, apiToken string) *HTTPClient { + return &HTTPClient{ + baseURL: baseURL, + email: email, + apiToken: apiToken, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *HTTPClient) doRequest(method, endpoint string, query url.Values) ([]byte, error) { + // Build URL by joining base URL and endpoint, handling trailing slashes + baseURL := strings.TrimSuffix(c.baseURL, "/") + fullURL := baseURL + endpoint + if query != nil { + fullURL += "?" + query.Encode() + } + + u, err := url.Parse(fullURL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + req, err := http.NewRequest(method, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.SetBasicAuth(c.email, c.apiToken) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + return body, nil +} + +func (c *HTTPClient) SearchIssues(project, status string) ([]Issue, error) { + // JQL: project keys work without quotes, status with spaces needs quotes + // assignee = currentUser() filters to only issues assigned to the authenticated user + jql := fmt.Sprintf(`project = %s AND status = "%s" AND assignee = currentUser() ORDER BY updated DESC`, project, status) + + query := url.Values{} + query.Set("jql", jql) + query.Set("fields", "summary,status,issuetype,priority,assignee,reporter,created,updated") + query.Set("maxResults", "50") + + body, err := c.doRequest(http.MethodGet, "/rest/api/3/search/jql", query) + if err != nil { + return nil, err + } + + var result SearchResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return result.Issues, nil +} + +func (c *HTTPClient) GetIssue(key string) (*Issue, error) { + endpoint := fmt.Sprintf("/rest/api/3/issue/%s", key) + + query := url.Values{} + query.Set("fields", "summary,status,issuetype,priority,assignee,reporter,created,updated,description") + + body, err := c.doRequest(http.MethodGet, endpoint, query) + if err != nil { + return nil, err + } + + var issue Issue + if err := json.Unmarshal(body, &issue); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &issue, nil +} diff --git a/internal/jira/client_test.go b/internal/jira/client_test.go new file mode 100644 index 0000000..5da6abd --- /dev/null +++ b/internal/jira/client_test.go @@ -0,0 +1,164 @@ +package jira + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHTTPClient_SearchIssues(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/rest/api/3/search/jql" { + t.Errorf("unexpected path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + + // Verify auth header + user, pass, ok := r.BasicAuth() + if !ok || user != "test@example.com" || pass != "token123" { + t.Error("invalid auth credentials") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + jql := r.URL.Query().Get("jql") + if jql == "" { + t.Error("missing jql parameter") + } + + result := SearchResult{ + Total: 2, + Issues: []Issue{ + {Key: "TEST-1", Fields: IssueFields{Summary: "First issue", Status: Status{Name: "In Progress"}}}, + {Key: "TEST-2", Fields: IssueFields{Summary: "Second issue", Status: Status{Name: "In Progress"}}}, + }, + } + + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(result) + if err != nil { + t.Errorf("failed to encode response: %v", err) + } + })) + defer server.Close() + + client := NewClient(server.URL, "test@example.com", "token123") + issues, err := client.SearchIssues("TEST", "In Progress") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(issues) != 2 { + t.Errorf("expected 2 issues, got %d", len(issues)) + } + + if issues[0].Key != "TEST-1" { + t.Errorf("expected first issue key TEST-1, got %s", issues[0].Key) + } +} + +func TestHTTPClient_GetIssue(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/rest/api/3/issue/TEST-123" { + t.Errorf("unexpected path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + + issue := Issue{ + Key: "TEST-123", + Fields: IssueFields{ + Summary: "Test issue", + Description: json.RawMessage(`{"type":"doc","content":[]}`), + Status: Status{Name: "In Progress"}, + IssueType: Type{Name: "Task"}, + }, + } + + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(issue) + if err != nil { + t.Errorf("failed to encode response: %v", err) + } + })) + defer server.Close() + + client := NewClient(server.URL, "test@example.com", "token123") + issue, err := client.GetIssue("TEST-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if issue.Key != "TEST-123" { + t.Errorf("expected key TEST-123, got %s", issue.Key) + } + if issue.Fields.Summary != "Test issue" { + t.Errorf("expected summary 'Test issue', got %q", issue.Fields.Summary) + } +} + +func TestHTTPClient_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"errorMessages":["Issue not found"]}`, http.StatusNotFound) + })) + defer server.Close() + + client := NewClient(server.URL, "test@example.com", "token123") + _, err := client.GetIssue("NONEXISTENT-999") + if err == nil { + t.Error("expected error for non-existent issue") + } +} + +func TestMockClient(t *testing.T) { + mock := NewMockClient() + mock.AddIssue(Issue{ + Key: "MOCK-1", + Fields: IssueFields{ + Summary: "Mock issue", + Status: Status{Name: "In Progress"}, + }, + }) + mock.AddIssue(Issue{ + Key: "MOCK-2", + Fields: IssueFields{ + Summary: "Done issue", + Status: Status{Name: "Done"}, + }, + }) + + t.Run("SearchIssues filters by status", func(t *testing.T) { + issues, err := mock.SearchIssues("MOCK", "In Progress") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(issues) != 1 { + t.Errorf("expected 1 issue, got %d", len(issues)) + } + if issues[0].Key != "MOCK-1" { + t.Errorf("expected MOCK-1, got %s", issues[0].Key) + } + }) + + t.Run("GetIssue returns issue by key", func(t *testing.T) { + issue, err := mock.GetIssue("MOCK-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if issue.Key != "MOCK-1" { + t.Errorf("expected MOCK-1, got %s", issue.Key) + } + }) + + t.Run("GetIssue returns error for unknown key", func(t *testing.T) { + _, err := mock.GetIssue("UNKNOWN-999") + if err == nil { + t.Error("expected error for unknown key") + } + if _, ok := err.(*NotFoundError); !ok { + t.Errorf("expected NotFoundError, got %T", err) + } + }) +} diff --git a/internal/jira/mock.go b/internal/jira/mock.go new file mode 100644 index 0000000..5529164 --- /dev/null +++ b/internal/jira/mock.go @@ -0,0 +1,53 @@ +package jira + +type MockClient struct { + Issues []Issue + IssueByKey map[string]*Issue + SearchErr error + GetErr error +} + +func NewMockClient() *MockClient { + return &MockClient{ + IssueByKey: make(map[string]*Issue), + } +} + +func (m *MockClient) AddIssue(issue Issue) { + m.Issues = append(m.Issues, issue) + m.IssueByKey[issue.Key] = &issue +} + +func (m *MockClient) SearchIssues(project, status string) ([]Issue, error) { + if m.SearchErr != nil { + return nil, m.SearchErr + } + + var filtered []Issue + for _, issue := range m.Issues { + if issue.Fields.Status.Name == status { + filtered = append(filtered, issue) + } + } + return filtered, nil +} + +func (m *MockClient) GetIssue(key string) (*Issue, error) { + if m.GetErr != nil { + return nil, m.GetErr + } + + issue, ok := m.IssueByKey[key] + if !ok { + return nil, &NotFoundError{Key: key} + } + return issue, nil +} + +type NotFoundError struct { + Key string +} + +func (e *NotFoundError) Error() string { + return "issue not found: " + e.Key +} diff --git a/internal/jira/types.go b/internal/jira/types.go new file mode 100644 index 0000000..dbb1349 --- /dev/null +++ b/internal/jira/types.go @@ -0,0 +1,53 @@ +package jira + +import ( + "encoding/json" + "time" +) + +type Issue struct { + Key string `json:"key"` + Fields IssueFields `json:"fields"` +} + +type IssueFields struct { + Summary string `json:"summary"` + Description json.RawMessage `json:"description"` // ADF format in API v3 + Status Status `json:"status"` + IssueType Type `json:"issuetype"` + Priority *Priority `json:"priority,omitempty"` + Assignee *User `json:"assignee,omitempty"` + Reporter *User `json:"reporter,omitempty"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +type Status struct { + Name string `json:"name"` +} + +type Type struct { + Name string `json:"name"` +} + +type Priority struct { + Name string `json:"name"` +} + +type User struct { + DisplayName string `json:"displayName"` + EmailAddress string `json:"emailAddress"` +} + +type SearchResult struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + Issues []Issue `json:"issues"` +} + +type SelectedIssue struct { + Key string `json:"key"` + Summary string `json:"summary"` + SelectedAt time.Time `json:"selected_at"` +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..72da5e5 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,99 @@ +package state + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +type CurrentIssue struct { + Key string `json:"key"` + Summary string `json:"summary"` + SelectedAt time.Time `json:"selected_at"` +} + +type State struct { + CurrentIssue *CurrentIssue `json:"current_issue,omitempty"` +} + +func StateDir() (string, error) { + if xdgState := os.Getenv("XDG_STATE_HOME"); xdgState != "" { + return filepath.Join(xdgState, "jcli"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(home, ".local", "state", "jcli"), nil +} + +func StatePath() (string, error) { + dir, err := StateDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "state.json"), nil +} + +func Load() (*State, error) { + path, err := StatePath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return &State{}, nil + } + if err != nil { + return nil, fmt.Errorf("failed to read state file: %w", err) + } + + var state State + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("failed to parse state file: %w", err) + } + + return &state, nil +} + +func (s *State) Save() error { + path, err := StatePath() + if err != nil { + return err + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("failed to create state directory: %w", err) + } + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal state: %w", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("failed to write state file: %w", err) + } + + return nil +} + +func (s *State) SetCurrentIssue(key, summary string) { + s.CurrentIssue = &CurrentIssue{ + Key: key, + Summary: summary, + SelectedAt: time.Now(), + } +} + +func (s *State) ClearCurrentIssue() { + s.CurrentIssue = nil +} + +func (s *State) HasCurrentIssue() bool { + return s.CurrentIssue != nil +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go new file mode 100644 index 0000000..63492db --- /dev/null +++ b/internal/state/state_test.go @@ -0,0 +1,117 @@ +package state + +import ( + "os" + "path/filepath" + "testing" +) + +func TestStateDir(t *testing.T) { + t.Run("uses XDG_STATE_HOME if set", func(t *testing.T) { + t.Setenv("XDG_STATE_HOME", "/custom/state") + dir, err := StateDir() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := "/custom/state/jcli" + if dir != expected { + t.Errorf("expected %q, got %q", expected, dir) + } + }) + + t.Run("falls back to ~/.local/state/jcli", func(t *testing.T) { + t.Setenv("XDG_STATE_HOME", "") + dir, err := StateDir() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + home, _ := os.UserHomeDir() + expected := filepath.Join(home, ".local", "state", "jcli") + if dir != expected { + t.Errorf("expected %q, got %q", expected, dir) + } + }) +} + +func TestLoadAndSave(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", tmpDir) + + // Load should return empty state when file doesn't exist + s, err := Load() + if err != nil { + t.Fatalf("unexpected error loading non-existent state: %v", err) + } + if s.HasCurrentIssue() { + t.Error("expected no current issue in empty state") + } + + // Set and save + s.SetCurrentIssue("TEST-123", "Test summary") + if err := s.Save(); err != nil { + t.Fatalf("unexpected error saving state: %v", err) + } + + // Reload and verify + loaded, err := Load() + if err != nil { + t.Fatalf("unexpected error loading state: %v", err) + } + + if !loaded.HasCurrentIssue() { + t.Fatal("expected current issue to be set") + } + if loaded.CurrentIssue.Key != "TEST-123" { + t.Errorf("expected key TEST-123, got %q", loaded.CurrentIssue.Key) + } + if loaded.CurrentIssue.Summary != "Test summary" { + t.Errorf("expected summary 'Test summary', got %q", loaded.CurrentIssue.Summary) + } + if loaded.CurrentIssue.SelectedAt.IsZero() { + t.Error("expected SelectedAt to be set") + } +} + +func TestSetCurrentIssue(t *testing.T) { + s := &State{} + + s.SetCurrentIssue("PROJ-456", "My task") + + if s.CurrentIssue == nil { + t.Fatal("expected CurrentIssue to be set") + } + if s.CurrentIssue.Key != "PROJ-456" { + t.Errorf("expected key PROJ-456, got %q", s.CurrentIssue.Key) + } + if s.CurrentIssue.Summary != "My task" { + t.Errorf("expected summary 'My task', got %q", s.CurrentIssue.Summary) + } +} + +func TestClearCurrentIssue(t *testing.T) { + s := &State{} + s.SetCurrentIssue("TEST-1", "Test") + + if !s.HasCurrentIssue() { + t.Fatal("expected HasCurrentIssue to be true before clear") + } + + s.ClearCurrentIssue() + + if s.HasCurrentIssue() { + t.Error("expected HasCurrentIssue to be false after clear") + } +} + +func TestHasCurrentIssue(t *testing.T) { + s := &State{} + + if s.HasCurrentIssue() { + t.Error("expected HasCurrentIssue to be false for empty state") + } + + s.CurrentIssue = &CurrentIssue{Key: "X-1"} + if !s.HasCurrentIssue() { + t.Error("expected HasCurrentIssue to be true when issue is set") + } +} diff --git a/internal/tui/selector.go b/internal/tui/selector.go new file mode 100644 index 0000000..ab067d6 --- /dev/null +++ b/internal/tui/selector.go @@ -0,0 +1,98 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/tutunak/jcli/internal/jira" +) + +type Selector struct{} + +func NewSelector() *Selector { + return &Selector{} +} + +func (s *Selector) SelectIssue(issues []jira.Issue) (*jira.Issue, error) { + if len(issues) == 0 { + return nil, fmt.Errorf("no issues available to select") + } + + options := make([]huh.Option[string], len(issues)) + issueMap := make(map[string]*jira.Issue) + + for i, issue := range issues { + label := fmt.Sprintf("%s: %s", issue.Key, issue.Fields.Summary) + if len(label) > 80 { + label = label[:77] + "..." + } + options[i] = huh.NewOption(label, issue.Key) + issueMap[issue.Key] = &issues[i] + } + + var selectedKey string + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select an issue"). + Options(options...). + Value(&selectedKey), + ), + ) + + if err := form.Run(); err != nil { + return nil, fmt.Errorf("selection cancelled: %w", err) + } + + selected, ok := issueMap[selectedKey] + if !ok { + return nil, fmt.Errorf("selected issue not found") + } + + return selected, nil +} + +func (s *Selector) PromptCredentials() (url, email, token string, err error) { + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Jira URL"). + Description("e.g., https://company.atlassian.net"). + Value(&url). + Validate(func(str string) error { + if str == "" { + return fmt.Errorf("URL is required") + } + return nil + }), + huh.NewInput(). + Title("Email"). + Description("Your Jira account email"). + Value(&email). + Validate(func(str string) error { + if str == "" { + return fmt.Errorf("email is required") + } + return nil + }), + huh.NewInput(). + Title("API Token"). + Description("Create at https://id.atlassian.com/manage-profile/security/api-tokens"). + EchoMode(huh.EchoModePassword). + Value(&token). + Validate(func(str string) error { + if str == "" { + return fmt.Errorf("API token is required") + } + return nil + }), + ), + ) + + if err := form.Run(); err != nil { + return "", "", "", fmt.Errorf("credentials input cancelled: %w", err) + } + + return url, email, token, nil +} diff --git a/internal/tui/selector_test.go b/internal/tui/selector_test.go new file mode 100644 index 0000000..b9cc910 --- /dev/null +++ b/internal/tui/selector_test.go @@ -0,0 +1,26 @@ +package tui + +import ( + "testing" + + "github.com/tutunak/jcli/internal/jira" +) + +func TestNewSelector(t *testing.T) { + s := NewSelector() + if s == nil { + t.Error("expected non-nil selector") + } +} + +func TestSelectIssue_EmptyList(t *testing.T) { + s := NewSelector() + _, err := s.SelectIssue([]jira.Issue{}) + if err == nil { + t.Error("expected error for empty issue list") + } +} + +// Note: Interactive tests for SelectIssue and PromptCredentials +// would require mocking the terminal, which is complex. +// These are better tested through integration tests or manual testing. diff --git a/main.go b/main.go new file mode 100644 index 0000000..2eccd01 --- /dev/null +++ b/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + "os" + + "github.com/tutunak/jcli/cmd" +) + +var version = "dev" + +func main() { + cmd.SetVersion(version) + if err := cmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go new file mode 100644 index 0000000..0ad6af4 --- /dev/null +++ b/tests/integration/integration_test.go @@ -0,0 +1,214 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestCLIIntegration(t *testing.T) { + // Build the CLI + tmpBin := filepath.Join(t.TempDir(), "jcli") + cmd := exec.Command("go", "build", "-o", tmpBin, "../../.") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to build: %v\n%s", err, output) + } + + // Setup test directories + configDir := t.TempDir() + stateDir := t.TempDir() + + // Create mock Jira server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/rest/api/3/search"): + json.NewEncoder(w).Encode(map[string]interface{}{ + "total": 2, + "issues": []map[string]interface{}{ + { + "key": "TEST-1", + "fields": map[string]interface{}{ + "summary": "First test issue", + "status": map[string]string{"name": "In Progress"}, + }, + }, + { + "key": "TEST-2", + "fields": map[string]interface{}{ + "summary": "Second test issue", + "status": map[string]string{"name": "In Progress"}, + }, + }, + }, + }) + case strings.HasPrefix(r.URL.Path, "/rest/api/3/issue/"): + key := strings.TrimPrefix(r.URL.Path, "/rest/api/3/issue/") + json.NewEncoder(w).Encode(map[string]interface{}{ + "key": key, + "fields": map[string]interface{}{ + "summary": "Test issue " + key, + "status": map[string]string{"name": "In Progress"}, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + // Helper to run CLI + runCLI := func(args ...string) (string, error) { + cmd := exec.Command(tmpBin, args...) + cmd.Env = append(os.Environ(), + "XDG_CONFIG_HOME="+configDir, + "XDG_STATE_HOME="+stateDir, + ) + output, err := cmd.CombinedOutput() + return string(output), err + } + + // Test version + t.Run("version", func(t *testing.T) { + output, err := runCLI("version") + if err != nil { + t.Fatalf("version failed: %v", err) + } + if !strings.Contains(output, "jcli version") { + t.Errorf("unexpected version output: %s", output) + } + }) + + // Test help + t.Run("help", func(t *testing.T) { + output, err := runCLI("help") + if err != nil { + t.Fatalf("help failed: %v", err) + } + if !strings.Contains(output, "jcli - Jira CLI") { + t.Errorf("unexpected help output: %s", output) + } + }) + + // Test config project + t.Run("config project", func(t *testing.T) { + output, err := runCLI("config", "project", "TEST") + if err != nil { + t.Fatalf("config project failed: %v", err) + } + if !strings.Contains(output, "Default project set to: TEST") { + t.Errorf("unexpected output: %s", output) + } + + // Verify config file was created + configFile := filepath.Join(configDir, "jcli", "config.yaml") + data, err := os.ReadFile(configFile) + if err != nil { + t.Fatalf("failed to read config: %v", err) + } + + var cfg map[string]interface{} + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("failed to parse config: %v", err) + } + + defaults := cfg["defaults"].(map[string]interface{}) + if defaults["project"] != "TEST" { + t.Errorf("expected project TEST, got %v", defaults["project"]) + } + }) + + // Test config status + t.Run("config status", func(t *testing.T) { + output, err := runCLI("config", "status", "Done") + if err != nil { + t.Fatalf("config status failed: %v", err) + } + if !strings.Contains(output, "Default status filter set to: Done") { + t.Errorf("unexpected output: %s", output) + } + }) + + // Test config show + t.Run("config show", func(t *testing.T) { + output, err := runCLI("config", "show") + if err != nil { + t.Fatalf("config show failed: %v", err) + } + if !strings.Contains(output, "Project: TEST") { + t.Errorf("unexpected output: %s", output) + } + if !strings.Contains(output, "Status: Done") { + t.Errorf("unexpected output: %s", output) + } + }) + + // Setup full config for issue commands + configFile := filepath.Join(configDir, "jcli", "config.yaml") + cfg := map[string]interface{}{ + "jira": map[string]string{ + "url": server.URL, + "email": "test@example.com", + "api_token": "test-token", + }, + "defaults": map[string]string{ + "project": "TEST", + "status": "In Progress", + }, + } + data, _ := yaml.Marshal(cfg) + os.WriteFile(configFile, data, 0600) + + // Test issue select with issue ID + t.Run("issue select by ID", func(t *testing.T) { + output, err := runCLI("issue", "select", "TEST-123") + if err != nil { + t.Fatalf("issue select failed: %v\n%s", err, output) + } + if !strings.Contains(output, "Selected: TEST-123") { + t.Errorf("unexpected output: %s", output) + } + }) + + // Test issue current + t.Run("issue current", func(t *testing.T) { + output, err := runCLI("issue", "current") + if err != nil { + t.Fatalf("issue current failed: %v", err) + } + if !strings.Contains(output, "Current issue: TEST-123") { + t.Errorf("unexpected output: %s", output) + } + }) + + // Test issue branch + t.Run("issue branch", func(t *testing.T) { + output, err := runCLI("issue", "branch") + if err != nil { + t.Fatalf("issue branch failed: %v", err) + } + output = strings.TrimSpace(output) + if !strings.HasPrefix(output, "TEST-123-") { + t.Errorf("branch should start with 'TEST-123-', got: %s", output) + } + }) + + // Test issue help + t.Run("issue help", func(t *testing.T) { + output, err := runCLI("issue", "help") + if err != nil { + t.Fatalf("issue help failed: %v", err) + } + if !strings.Contains(output, "jcli issue - Manage Jira issues") { + t.Errorf("unexpected output: %s", output) + } + }) +}