From 2555afce193123dea6630b8db168222c38c3acbe Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 4 Dec 2025 09:56:11 +0100 Subject: [PATCH] Initial commit: Azure DevOps TUI client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal-based user interface for browsing and managing Azure DevOps work items. Features include: - Browse work items with filtering by area, iteration, state, and type - View work item details with markdown rendering - Open work items in browser - Create git branches from work items - Update work item state - Keyboard-driven navigation πŸ€– Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude --- .gitignore | 34 ++ README.md | 111 ++++++ SPEC.md | 447 ++++++++++++++++++++++ cmd/root.go | 44 +++ go.mod | 53 +++ go.sum | 121 ++++++ internal/api/areas.go | 95 +++++ internal/api/client.go | 179 +++++++++ internal/api/iterations.go | 83 ++++ internal/api/workitems.go | 273 +++++++++++++ internal/api/workitemtypes.go | 101 +++++ internal/config/config.go | 142 +++++++ internal/config/state.go | 80 ++++ internal/models/area.go | 25 ++ internal/models/filter.go | 317 +++++++++++++++ internal/models/iteration.go | 37 ++ internal/models/workitem.go | 92 +++++ internal/ui/app.go | 532 ++++++++++++++++++++++++++ internal/ui/components/branchmodal.go | 251 ++++++++++++ internal/ui/components/details.go | 212 ++++++++++ internal/ui/components/detailview.go | 211 ++++++++++ internal/ui/components/filter.go | 223 +++++++++++ internal/ui/components/help.go | 173 +++++++++ internal/ui/components/statemodal.go | 223 +++++++++++ internal/ui/components/workitems.go | 367 ++++++++++++++++++ internal/ui/theme/keys.go | 129 +++++++ internal/ui/theme/styles.go | 234 +++++++++++ main.go | 15 + pkg/browser/open.go | 26 ++ pkg/git/branch.go | 76 ++++ 30 files changed, 4906 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 cmd/root.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/areas.go create mode 100644 internal/api/client.go create mode 100644 internal/api/iterations.go create mode 100644 internal/api/workitems.go create mode 100644 internal/api/workitemtypes.go create mode 100644 internal/config/config.go create mode 100644 internal/config/state.go create mode 100644 internal/models/area.go create mode 100644 internal/models/filter.go create mode 100644 internal/models/iteration.go create mode 100644 internal/models/workitem.go create mode 100644 internal/ui/app.go create mode 100644 internal/ui/components/branchmodal.go create mode 100644 internal/ui/components/details.go create mode 100644 internal/ui/components/detailview.go create mode 100644 internal/ui/components/filter.go create mode 100644 internal/ui/components/help.go create mode 100644 internal/ui/components/statemodal.go create mode 100644 internal/ui/components/workitems.go create mode 100644 internal/ui/theme/keys.go create mode 100644 internal/ui/theme/styles.go create mode 100644 main.go create mode 100644 pkg/browser/open.go create mode 100644 pkg/git/branch.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffae1fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Binaries +devops-tui +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of go coverage +*.out + +# Go workspace +go.work +go.work.sum + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Config with secrets +.env +config.yaml + +# Serena +.serena/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f80336f --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# devops-tui + +A terminal user interface (TUI) for Azure DevOps Boards, inspired by [jira-cli](https://github.com/ankitpokhrel/jira-cli) and [JiraTUI](https://jiratui.sh/). + +## Features + +- View Azure DevOps work items in a clean terminal interface +- Filter by Sprint, State, and Assigned To +- Vim-style navigation (j/k/g/G) +- Fullscreen detail view +- Open work items in browser +- Cross-platform (Windows, macOS, Linux) + +## Installation + +### Build from source + +```bash +go build -o devops-tui . +``` + +### Move to PATH + +```bash +mv devops-tui /usr/local/bin/ +``` + +## Configuration + +Create a config file at `~/.config/devops-tui/config.yaml`: + +```yaml +# Azure DevOps connection +organization: "my-organization" +project: "my-project" +team: "my-team" + +# Authentication (or use AZURE_DEVOPS_PAT env variable) +pat: "your-personal-access-token" + +# UI settings +theme: "default" + +# Default filters at startup +defaults: + sprint: "current" + state: "all" + assigned: "me" +``` + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `AZURE_DEVOPS_PAT` | Personal Access Token (recommended) | +| `AZURE_DEVOPS_ORG` | Organization (overrides config) | +| `AZURE_DEVOPS_PROJECT` | Project (overrides config) | +| `AZURE_DEVOPS_TEAM` | Team (overrides config) | + +### PAT Permissions + +Your Personal Access Token needs these scopes: +- `Work Items (Read)` - Read work items +- `Project and Team (Read)` - List sprints/iterations + +## Keyboard Shortcuts + +### Global + +| Key | Description | +|-----|-------------| +| `Tab` | Switch to next panel | +| `Shift+Tab` | Switch to previous panel | +| `?` | Show/hide help | +| `Ctrl+r` | Reload data | +| `q` / `Ctrl+c` | Quit | + +### Navigation + +| Key | Description | +|-----|-------------| +| `j` / `↓` | Move down | +| `k` / `↑` | Move up | +| `g` | Go to first item | +| `G` | Go to last item | + +### Actions + +| Key | Description | +|-----|-------------| +| `Enter` / `Space` | Select filter / Open in browser | +| `v` | View fullscreen details | + +### Detail View + +| Key | Description | +|-----|-------------| +| `Esc` / `q` | Back to main view | +| `Enter` | Open in browser | +| `j` / `k` | Scroll description | + +## Tech Stack + +- [Bubble Tea](https://github.com/charmbracelet/bubbletea) - TUI framework +- [Bubbles](https://github.com/charmbracelet/bubbles) - UI components +- [Lip Gloss](https://github.com/charmbracelet/lipgloss) - Styling +- [Viper](https://github.com/spf13/viper) - Configuration + +## License + +MIT diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..1671c79 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,447 @@ +# devops-tui - MVP Specifikation + +> En terminal user interface (TUI) fΓΆr Azure DevOps Boards, inspirerad av [jira-cli](https://github.com/ankitpokhrel/jira-cli) och [JiraTUI](https://jiratui.sh/). + +## Γ–versikt + +**devops-tui** Γ€r ett terminalbaserat verktyg fΓΆr att navigera och granska Azure DevOps work items direkt frΓ₯n kommandoraden. MVP:n fokuserar pΓ₯ read-only boards-funktionalitet med en panel-baserad layout inspirerad av Lazygit. + +### MΓ₯l + +- Snabb ΓΆverblick av work items utan att lΓ€mna terminalen +- Vim-inspirerad navigation fΓΆr effektivt arbetsflΓΆde +- Enkel installation via single binary (Go) + +### AvgrΓ€nsningar (MVP) + +| Inkluderat | Exkluderat | +|------------|------------| +| Boards (work items) | Pipelines/Builds | +| Read-only visning | Skapa/redigera items | +| Fasta filter (Sprint/State/Assigned) | WIQL-queries | +| Ett konfigurerat projekt | Projekt-switcher | +| Personal Access Token auth | OAuth/SSO | + +--- + +## Tech Stack + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ devops-tui β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ UI Layer β”‚ +β”‚ β”œβ”€ Bubble Tea (TUI framework) β”‚ +β”‚ β”œβ”€ Bubbles (komponenter) β”‚ +β”‚ └─ Lip Gloss (styling) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Data Layer β”‚ +β”‚ β”œβ”€ Azure DevOps REST API v7.1 β”‚ +β”‚ └─ HTTP client (net/http) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Config β”‚ +β”‚ β”œβ”€ Viper (konfiguration) β”‚ +β”‚ └─ YAML config file β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### VarfΓΆr Go + Bubble Tea? + +1. **Single binary** - Enkel distribution, inga runtime-beroenden +2. **Cross-platform** - Windows, macOS, Linux utan extra arbete +3. **BeprΓΆvat** - AnvΓ€nds av GitHub CLI, lazygit, och jira-cli +4. **Bra ekosystem** - Charm.sh har kompletterande bibliotek + +### Dependencies + +```go +require ( + github.com/charmbracelet/bubbletea // TUI framework + github.com/charmbracelet/bubbles // UI komponenter + github.com/charmbracelet/lipgloss // Styling + github.com/spf13/viper // Konfiguration +) +``` + +--- + +## UI Design + +### Layout (3-panel) + +``` +β”Œβ”€ devops-tui ──────────────────────────────────────────────────┐ +β”‚ β”‚ +β”‚ β”Œβ”€ FILTER ──────────┐ β”Œβ”€ WORK ITEMS ────────────────────────┐ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Sprint β”‚ β”‚ ID TYPE STATE TITLE β”‚ β”‚ +β”‚ β”‚ ───────────── β”‚ β”‚ ───────────────────────────────── β”‚ β”‚ +β”‚ β”‚ β–Έ Sprint 42 β”‚ β”‚ #1234 Story Active Implement β”‚ β”‚ +β”‚ β”‚ Sprint 41 β”‚ β”‚β–Έ #1235 Task Active Create lo β”‚ β”‚ +β”‚ β”‚ Sprint 40 β”‚ β”‚ #1236 Task New Add JWT m β”‚ β”‚ +β”‚ β”‚ Backlog β”‚ β”‚ #1237 Bug Active Fix token β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ #1238 Task Resolved Setup CI β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ +β”‚ β”‚ State β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ ───────────── β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ ● All β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β—‹ New β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β—‹ Active β”‚ β”Œβ”€ DETAILS ───────────────────────────┐ β”‚ +β”‚ β”‚ β—‹ Resolved β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β—‹ Closed β”‚ β”‚ #1235 Create login component β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ +β”‚ β”‚ Assigned β”‚ β”‚ Type: Task State: Active β”‚ β”‚ +β”‚ β”‚ ───────────── β”‚ β”‚ Assigned: Samuel Sprint: 42 β”‚ β”‚ +β”‚ β”‚ ● All β”‚ β”‚ Area: Frontend Priority: 2 β”‚ β”‚ +β”‚ β”‚ β—‹ Me β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ Parent: #1234 Implement auth flow β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ +β”‚ β”‚ ─── Description ─── β”‚ β”‚ +β”‚ β”‚ Create a React login component β”‚ β”‚ +β”‚ β”‚ with email/password fields... β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ ─── Tags ─── β”‚ β”‚ +β”‚ β”‚ frontend Β· react Β· auth β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Tab Panel j/k Navigate g/G Top/Bottom Enter Open ? Help β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Paneler + +| Panel | Bredd | InnehΓ₯ll | Interaktion | +|-------|-------|----------|-------------| +| **Filter** | ~20% | Sprint, State, Assigned filter | VΓ€lj filter med Enter/Space | +| **Work Items** | ~80% (topp) | Tabell med work items | Navigera med j/k, ΓΆppna med Enter | +| **Details** | ~80% (botten) | Detaljer fΓΆr valt item | Automatiskt uppdaterad | + +### FullskΓ€rms-detaljvy + +NΓ€r anvΓ€ndaren trycker `v` pΓ₯ ett work item: + +``` +β”Œβ”€ #1235 Create login component ────────────────────────────────┐ +β”‚ β”‚ +β”‚ β”Œβ”€ METADATA ───────────────────────────────────────────────┐ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Type: Task ID: #1235 β”‚ β”‚ +β”‚ β”‚ State: Active Created: 2024-01-15 β”‚ β”‚ +β”‚ β”‚ Assigned: Samuel Enocsson Updated: 2024-01-18 β”‚ β”‚ +β”‚ β”‚ Sprint: Sprint 42 Priority: 2 β”‚ β”‚ +β”‚ β”‚ Area: Frontend β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€ PARENT ─────────────────────────────────────────────────┐ β”‚ +β”‚ β”‚ #1234 User Story: Implement authentication flow β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€ DESCRIPTION ────────────────────────────────────────────┐ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Create a React login component with the following: β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ - Email input field with validation β”‚ β”‚ +β”‚ β”‚ - Password input field β”‚ β”‚ +β”‚ β”‚ - "Remember me" checkbox β”‚ β”‚ +β”‚ β”‚ - Submit button with loading state β”‚ β”‚ +β”‚ β”‚ - Error message display β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ The component should follow our design system and β”‚ β”‚ +β”‚ β”‚ integrate with the existing auth context. β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€ TAGS ───────────────────────────────────────────────────┐ β”‚ +β”‚ β”‚ frontend Β· react Β· auth Β· ui β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Esc Back Enter Open in browser j/k Scroll β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Keyboard Shortcuts + +### Globala + +| Key | Beskrivning | +|-----|-------------| +| `Tab` | Byt till nΓ€sta panel | +| `Shift+Tab` | Byt till fΓΆregΓ₯ende panel | +| `?` | Visa/dΓΆlj hjΓ€lp | +| `Ctrl+r` | Ladda om data | +| `q` / `Ctrl+c` | Avsluta | + +### Filter-panel + +| Key | Beskrivning | +|-----|-------------| +| `j` / `↓` | NΓ€sta filter/val | +| `k` / `↑` | FΓΆregΓ₯ende filter/val | +| `Enter` / `Space` | VΓ€lj filter | +| `g` | GΓ₯ till fΓΆrsta | +| `G` | GΓ₯ till sista | + +### Work Items-panel + +| Key | Beskrivning | +|-----|-------------| +| `j` / `↓` | NΓ€sta work item | +| `k` / `↑` | FΓΆregΓ₯ende work item | +| `g` | GΓ₯ till fΓΆrsta | +| `G` | GΓ₯ till sista | +| `Enter` | Γ–ppna i webblΓ€sare | +| `v` | Visa fullskΓ€rms-detaljer | +| `/` | SΓΆk (filter by text) | + +### Detaljvy (fullskΓ€rm) + +| Key | Beskrivning | +|-----|-------------| +| `Esc` / `q` | Tillbaka till huvudvy | +| `Enter` | Γ–ppna i webblΓ€sare | +| `j` / `k` | Scrolla beskrivning | + +--- + +## Konfiguration + +### Config-fil + +Placering: `~/.config/devops-tui/config.yaml` + +```yaml +# Azure DevOps-anslutning +organization: "my-organization" +project: "my-project" + +# Autentisering +# PAT kan anges hΓ€r eller via miljΓΆvariabel AZURE_DEVOPS_PAT +pat: "" + +# UI-instΓ€llningar +theme: "default" # default, dark, light + +# Standardfilter vid uppstart +defaults: + sprint: "current" # "current", "all", eller specifikt namn + state: "all" # "all", "new", "active", "resolved", "closed" + assigned: "me" # "all", "me" +``` + +### MiljΓΆvariabler + +| Variabel | Beskrivning | +|----------|-------------| +| `AZURE_DEVOPS_PAT` | Personal Access Token (rekommenderat) | +| `AZURE_DEVOPS_ORG` | Organisation (override config) | +| `AZURE_DEVOPS_PROJECT` | Projekt (override config) | + +### PAT-behΓΆrigheter + +Personal Access Token behΓΆver fΓΆljande scope: +- `Work Items (Read)` - LΓ€sa work items +- `Project and Team (Read)` - Lista sprints/iterationer + +--- + +## Azure DevOps API + +### Endpoints som anvΓ€nds + +``` +Base URL: https://dev.azure.com/{organization}/{project}/_apis + +Work Items: + GET /wit/wiql # KΓΆr WIQL-query + GET /wit/workitems/{id} # HΓ€mta enskilt work item + GET /wit/workitems?ids={ids} # HΓ€mta flera work items + +Iterationer (Sprints): + GET /work/teamsettings/iterations # Lista iterationer + +Team: + GET /teams # Lista teams +``` + +### WIQL-queries + +FΓΆr att hΓ€mta work items anvΓ€nder vi WIQL (Work Item Query Language): + +```sql +-- Alla items i current sprint, tilldelade mig +SELECT [System.Id], [System.Title], [System.State], [System.WorkItemType] +FROM WorkItems +WHERE [System.TeamProject] = @project + AND [System.IterationPath] = @currentIteration + AND [System.AssignedTo] = @me +ORDER BY [System.ChangedDate] DESC + +-- Alla aktiva items +SELECT [System.Id], [System.Title], [System.State], [System.WorkItemType] +FROM WorkItems +WHERE [System.TeamProject] = @project + AND [System.State] = 'Active' +ORDER BY [System.ChangedDate] DESC +``` + +### Response-struktur (Work Item) + +```json +{ + "id": 1235, + "rev": 5, + "fields": { + "System.Id": 1235, + "System.Title": "Create login component", + "System.State": "Active", + "System.WorkItemType": "Task", + "System.AssignedTo": { + "displayName": "Samuel Enocsson", + "uniqueName": "samuel@example.com" + }, + "System.IterationPath": "MyProject\\Sprint 42", + "System.AreaPath": "MyProject\\Frontend", + "System.Description": "
Create a React login...
", + "System.Tags": "frontend; react; auth", + "System.Parent": 1234, + "Microsoft.VSTS.Common.Priority": 2, + "System.CreatedDate": "2024-01-15T10:00:00Z", + "System.ChangedDate": "2024-01-18T14:30:00Z" + }, + "url": "https://dev.azure.com/org/project/_apis/wit/workItems/1235" +} +``` + +--- + +## Projektstruktur + +``` +devops-tui/ +β”œβ”€β”€ main.go # Entry point +β”œβ”€β”€ go.mod +β”œβ”€β”€ go.sum +β”œβ”€β”€ README.md +β”œβ”€β”€ SPEC.md # Denna fil +β”‚ +β”œβ”€β”€ cmd/ +β”‚ └── root.go # CLI setup (cobra om vi vill ha subcommands) +β”‚ +β”œβ”€β”€ internal/ +β”‚ β”œβ”€β”€ config/ +β”‚ β”‚ └── config.go # Viper config loading +β”‚ β”‚ +β”‚ β”œβ”€β”€ api/ +β”‚ β”‚ β”œβ”€β”€ client.go # Azure DevOps HTTP client +β”‚ β”‚ β”œβ”€β”€ workitems.go # Work item queries +β”‚ β”‚ └── iterations.go # Sprint/iteration queries +β”‚ β”‚ +β”‚ β”œβ”€β”€ ui/ +β”‚ β”‚ β”œβ”€β”€ app.go # Bubble Tea main model +β”‚ β”‚ β”œβ”€β”€ styles.go # Lip Gloss styles +β”‚ β”‚ β”œβ”€β”€ keys.go # Keyboard bindings +β”‚ β”‚ β”‚ +β”‚ β”‚ β”œβ”€β”€ components/ +β”‚ β”‚ β”‚ β”œβ”€β”€ filter.go # Filter panel +β”‚ β”‚ β”‚ β”œβ”€β”€ workitems.go # Work items list +β”‚ β”‚ β”‚ β”œβ”€β”€ details.go # Details panel +β”‚ β”‚ β”‚ β”œβ”€β”€ detailview.go # Fullscreen detail view +β”‚ β”‚ β”‚ └── help.go # Help overlay +β”‚ β”‚ β”‚ +β”‚ β”‚ └── views/ +β”‚ β”‚ β”œβ”€β”€ main.go # Main 3-panel view +β”‚ β”‚ └── detail.go # Fullscreen detail view +β”‚ β”‚ +β”‚ └── models/ +β”‚ β”œβ”€β”€ workitem.go # Work item domain model +β”‚ β”œβ”€β”€ iteration.go # Sprint/iteration model +β”‚ └── filter.go # Filter state model +β”‚ +└── pkg/ + └── browser/ + └── open.go # Cross-platform browser open +``` + +--- + +## Implementation - Milstolpar + +### Fas 1: Grundstruktur +- [ ] Initiera Go-modul +- [ ] SΓ€tt upp projektstruktur +- [ ] Konfigurera Bubble Tea scaffold +- [ ] Implementera config-laddning (Viper) + +### Fas 2: Azure DevOps API +- [ ] HTTP-klient med PAT-auth +- [ ] HΓ€mta iterationer (sprints) +- [ ] KΓΆr WIQL-queries +- [ ] HΓ€mta work items med detaljer + +### Fas 3: UI - Paneler +- [ ] Filter-panel (sprint, state, assigned) +- [ ] Work items-lista med tabell +- [ ] Details-panel med preview +- [ ] Panel-navigation (Tab) + +### Fas 4: UI - Interaktion +- [ ] Vim-navigation (j/k/g/G) +- [ ] Filter-val pΓ₯verkar work items +- [ ] Γ–ppna i browser (Enter) +- [ ] FullskΓ€rms-detaljvy (v) + +### Fas 5: Polish +- [ ] HjΓ€lp-overlay (?) +- [ ] Refresh (Ctrl+r) +- [ ] Felhantering och loading states +- [ ] FΓ€rgtema och styling + +--- + +## Framtida features (post-MVP) + +Dessa features Γ€r medvetet exkluderade frΓ₯n MVP men kan lΓ€ggas till senare: + +### Prioritet 1 (nΓ€sta iteration) +- [ ] Pipelines/Builds-vy +- [ ] Pull Requests-vy +- [ ] Projekt-switcher + +### Prioritet 2 +- [ ] Skapa work items +- [ ] Redigera work items (state, assigned) +- [ ] WIQL query editor +- [ ] Kommentarer + +### Prioritet 3 +- [ ] Kanban board-vy +- [ ] Notifications +- [ ] Offline cache +- [ ] Themes (dark/light/custom) + +--- + +## Referenser + +### Inspiration +- [jira-cli](https://github.com/ankitpokhrel/jira-cli) - Go/Bubble Tea, tabellbaserad +- [JiraTUI](https://jiratui.sh/) - Python/Textual, panel-baserad +- [lazygit](https://github.com/jesseduffield/lazygit) - Go/gocui, panel-layout +- [k9s](https://github.com/derailed/k9s) - Go/tview, Kubernetes TUI + +### Dokumentation +- [Bubble Tea](https://github.com/charmbracelet/bubbletea) +- [Bubbles](https://github.com/charmbracelet/bubbles) +- [Lip Gloss](https://github.com/charmbracelet/lipgloss) +- [Azure DevOps REST API](https://learn.microsoft.com/en-us/rest/api/azure/devops/) +- [WIQL Syntax](https://learn.microsoft.com/en-us/azure/devops/boards/queries/wiql-syntax) + +### Azure DevOps Go Libraries +- [microsoft/azure-devops-go-api](https://github.com/microsoft/azure-devops-go-api) - Officiellt men begrΓ€nsat +- REST API direkt rekommenderas fΓΆr enklare implementation diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..15e9a2a --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/samuelenocsson/devops-tui/internal/api" + "github.com/samuelenocsson/devops-tui/internal/config" + "github.com/samuelenocsson/devops-tui/internal/ui" +) + +// Execute runs the application +func Execute() error { + // Load configuration + cfg, err := config.Load() + if err != nil { + // If config not found, try to create default + if err := config.CreateDefaultConfig(); err == nil { + fmt.Println("Created default config file at ~/.config/devops-tui/config.yaml") + fmt.Println("Please edit the config file with your Azure DevOps settings.") + os.Exit(0) + } + return fmt.Errorf("configuration error: %w", err) + } + + // Create API client + client := api.NewClient(cfg) + + // Create and run the TUI + app := ui.NewApp(client) + + p := tea.NewProgram( + app, + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + + if _, err := p.Run(); err != nil { + return fmt.Errorf("error running application: %w", err) + } + + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..146847c --- /dev/null +++ b/go.mod @@ -0,0 +1,53 @@ +module github.com/samuelenocsson/devops-tui + +go 1.25.4 + +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/spf13/viper v1.21.0 +) + +require ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/glamour v0.10.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gorilla/css v1.0.1 // 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/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/term v0.31.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fc8c02d --- /dev/null +++ b/go.sum @@ -0,0 +1,121 @@ +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +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/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +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/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +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/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +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/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +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= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/api/areas.go b/internal/api/areas.go new file mode 100644 index 0000000..f99df64 --- /dev/null +++ b/internal/api/areas.go @@ -0,0 +1,95 @@ +package api + +import ( + "sort" + "strings" + + "github.com/samuelenocsson/devops-tui/internal/models" +) + +// classificationNode represents an area node from the API +type classificationNode struct { + ID int `json:"id"` + Identifier string `json:"identifier"` + Name string `json:"name"` + Path string `json:"path"` + HasChildren bool `json:"hasChildren"` + Children []classificationNode `json:"children"` +} + +// GetAreas fetches all areas for the project +func (c *Client) GetAreas() ([]models.Area, error) { + // Use the classification nodes API with depth to get area hierarchy + resp, err := c.get("/wit/classificationnodes/areas?$depth=10") + if err != nil { + return nil, err + } + + var rootNode classificationNode + if err := decode(resp, &rootNode); err != nil { + return nil, err + } + + // Flatten the tree into a list + areas := flattenAreas(rootNode, "") + + // Sort areas by path for consistent ordering + sort.Slice(areas, func(i, j int) bool { + return areas[i].Path < areas[j].Path + }) + + return areas, nil +} + +// flattenAreas recursively flattens the area tree +func flattenAreas(node classificationNode, parentPath string) []models.Area { + var areas []models.Area + + // Build the path + path := node.Path + if path == "" { + path = node.Name + } + + // Clean up the path: + // 1. Remove leading backslash + // 2. Remove "\Area" from path (API returns \Project\Area\Team but work items use Project\Team) + path = strings.TrimPrefix(path, "\\") + path = strings.TrimSuffix(path, "\\") + + // Remove the "Area" segment from the path (e.g., "Project\Area\Team" -> "Project\Team") + parts := strings.Split(path, "\\") + if len(parts) >= 2 && parts[1] == "Area" { + // Remove the "Area" part + newParts := []string{parts[0]} + if len(parts) > 2 { + newParts = append(newParts, parts[2:]...) + } + path = strings.Join(newParts, "\\") + } + + // Add this node + areas = append(areas, models.Area{ + ID: node.ID, + Name: node.Name, + Path: path, + }) + + // Recursively add children + for _, child := range node.Children { + childAreas := flattenAreas(child, path) + areas = append(areas, childAreas...) + } + + return areas +} + +// GetAreaDisplayName returns a shortened display name for an area +func GetAreaDisplayName(path string) string { + parts := strings.Split(path, "\\") + if len(parts) > 1 { + // Return the last part for brevity + return parts[len(parts)-1] + } + return path +} diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..3d636fe --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,179 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/samuelenocsson/devops-tui/internal/config" +) + +const apiVersion = "7.1" + +// Client is the Azure DevOps API client +type Client struct { + httpClient *http.Client + baseURL string + teamURL string + webURL string + authHeader string + organization string + project string + team string +} + +// NewClient creates a new Azure DevOps API client +func NewClient(cfg *config.Config) *Client { + // Azure DevOps uses Basic auth with empty username and PAT as password + auth := base64.StdEncoding.EncodeToString([]byte(":" + cfg.PAT)) + + return &Client{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + baseURL: cfg.BaseURL(), + teamURL: cfg.TeamURL(), + webURL: cfg.WebURL(), + authHeader: "Basic " + auth, + organization: cfg.Organization, + project: cfg.Project, + team: cfg.Team, + } +} + +// doRequest performs an HTTP request with authentication +func (c *Client) doRequest(method, url string, body io.Reader) (*http.Response, error) { + return c.doRequestWithContentType(method, url, body, "application/json") +} + +// doRequestWithContentType performs an HTTP request with authentication and custom content type +func (c *Client) doRequestWithContentType(method, url string, body io.Reader, contentType string) (*http.Response, error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Authorization", c.authHeader) + req.Header.Set("Content-Type", contentType) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) + } + + return resp, nil +} + +// get performs a GET request to base URL +func (c *Client) get(endpoint string) (*http.Response, error) { + return c.getWithBase(c.baseURL, endpoint) +} + +// getTeam performs a GET request to team-specific URL +func (c *Client) getTeam(endpoint string) (*http.Response, error) { + return c.getWithBase(c.teamURL, endpoint) +} + +// getWithBase performs a GET request with a specific base URL +func (c *Client) getWithBase(baseURL, endpoint string) (*http.Response, error) { + url := fmt.Sprintf("%s%s", baseURL, endpoint) + if endpoint[0] != '/' { + url = fmt.Sprintf("%s/%s", baseURL, endpoint) + } + + // Add API version + if len(url) > 0 { + separator := "?" + if len(url) > 0 && url[len(url)-1] != '?' { + for _, c := range url { + if c == '?' { + separator = "&" + break + } + } + } + url = fmt.Sprintf("%s%sapi-version=%s", url, separator, apiVersion) + } + + return c.doRequest("GET", url, nil) +} + +// post performs a POST request +func (c *Client) post(endpoint string, body io.Reader) (*http.Response, error) { + url := fmt.Sprintf("%s%s", c.baseURL, endpoint) + if endpoint[0] != '/' { + url = fmt.Sprintf("%s/%s", c.baseURL, endpoint) + } + + // Add API version + separator := "?" + for _, ch := range url { + if ch == '?' { + separator = "&" + break + } + } + url = fmt.Sprintf("%s%sapi-version=%s", url, separator, apiVersion) + + return c.doRequest("POST", url, body) +} + +// patch performs a PATCH request (for work item updates) +func (c *Client) patch(endpoint string, body io.Reader) (*http.Response, error) { + url := fmt.Sprintf("%s%s", c.baseURL, endpoint) + if endpoint[0] != '/' { + url = fmt.Sprintf("%s/%s", c.baseURL, endpoint) + } + + // Add API version + separator := "?" + for _, ch := range url { + if ch == '?' { + separator = "&" + break + } + } + url = fmt.Sprintf("%s%sapi-version=%s", url, separator, apiVersion) + + return c.doRequestWithContentType("PATCH", url, body, "application/json-patch+json") +} + +// decode decodes a JSON response into the given target +func decode(resp *http.Response, target interface{}) error { + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(target); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + return nil +} + +// WorkItemWebURL returns the web URL for a work item +func (c *Client) WorkItemWebURL(id int) string { + return fmt.Sprintf("%s/_workitems/edit/%d", c.webURL, id) +} + +// Organization returns the organization name +func (c *Client) Organization() string { + return c.organization +} + +// Project returns the project name +func (c *Client) Project() string { + return c.project +} + +// Team returns the team name +func (c *Client) Team() string { + return c.team +} diff --git a/internal/api/iterations.go b/internal/api/iterations.go new file mode 100644 index 0000000..c0da42c --- /dev/null +++ b/internal/api/iterations.go @@ -0,0 +1,83 @@ +package api + +import ( + "time" + + "github.com/samuelenocsson/devops-tui/internal/models" +) + +// iterationsResponse represents the API response for iterations +type iterationsResponse struct { + Count int `json:"count"` + Value []iterationAPIItem `json:"value"` +} + +type iterationAPIItem struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Attributes iterationAttrs `json:"attributes"` + URL string `json:"url"` +} + +type iterationAttrs struct { + StartDate *time.Time `json:"startDate"` + FinishDate *time.Time `json:"finishDate"` + TimeFrame string `json:"timeFrame"` +} + +// GetIterations fetches all iterations (sprints) for the team +func (c *Client) GetIterations() ([]models.Iteration, error) { + resp, err := c.getTeam("/work/teamsettings/iterations") + if err != nil { + return nil, err + } + + var apiResp iterationsResponse + if err := decode(resp, &apiResp); err != nil { + return nil, err + } + + iterations := make([]models.Iteration, 0, apiResp.Count) + for _, item := range apiResp.Value { + iter := models.Iteration{ + ID: item.ID, + Name: item.Name, + Path: item.Path, + TimeFrame: item.Attributes.TimeFrame, + URL: item.URL, + } + + if item.Attributes.StartDate != nil { + iter.StartDate = *item.Attributes.StartDate + } + if item.Attributes.FinishDate != nil { + iter.FinishDate = *item.Attributes.FinishDate + } + + iterations = append(iterations, iter) + } + + return iterations, nil +} + +// GetCurrentIteration returns the current iteration +func (c *Client) GetCurrentIteration() (*models.Iteration, error) { + iterations, err := c.GetIterations() + if err != nil { + return nil, err + } + + for _, iter := range iterations { + if iter.IsCurrent() { + return &iter, nil + } + } + + // Return the first one if no current found + if len(iterations) > 0 { + return &iterations[0], nil + } + + return nil, nil +} diff --git a/internal/api/workitems.go b/internal/api/workitems.go new file mode 100644 index 0000000..a86741e --- /dev/null +++ b/internal/api/workitems.go @@ -0,0 +1,273 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + + "github.com/samuelenocsson/devops-tui/internal/models" +) + +// wiqlRequest represents a WIQL query request +type wiqlRequest struct { + Query string `json:"query"` +} + +// wiqlResponse represents the response from a WIQL query +type wiqlResponse struct { + WorkItems []struct { + ID int `json:"id"` + URL string `json:"url"` + } `json:"workItems"` +} + +// workItemsResponse represents the response for batch work item fetch +type workItemsResponse struct { + Count int `json:"count"` + Value []workItemAPIItem `json:"value"` +} + +// workItemAPIItem represents a work item from the API +type workItemAPIItem struct { + ID int `json:"id"` + Rev int `json:"rev"` + Fields workItemFields `json:"fields"` + URL string `json:"url"` +} + +type workItemFields struct { + ID int `json:"System.Id"` + Title string `json:"System.Title"` + State string `json:"System.State"` + WorkItemType string `json:"System.WorkItemType"` + AssignedTo *struct { + DisplayName string `json:"displayName"` + UniqueName string `json:"uniqueName"` + } `json:"System.AssignedTo"` + IterationPath string `json:"System.IterationPath"` + AreaPath string `json:"System.AreaPath"` + Description string `json:"System.Description"` + Tags string `json:"System.Tags"` + Parent int `json:"System.Parent"` + Priority int `json:"Microsoft.VSTS.Common.Priority"` + CreatedDate time.Time `json:"System.CreatedDate"` + ChangedDate time.Time `json:"System.ChangedDate"` +} + +// escapeWIQL escapes a string value for use in WIQL queries +func escapeWIQL(s string) string { + // Escape single quotes by doubling them + return strings.ReplaceAll(s, "'", "''") +} + +// QueryWorkItems queries work items using WIQL +func (c *Client) QueryWorkItems(sprintPath, state, assigned, areaPath string) ([]models.WorkItem, error) { + // Build WIQL query + query := `SELECT [System.Id], [System.Title], [System.State], [System.WorkItemType] +FROM WorkItems +WHERE [System.TeamProject] = @project` + + // Add sprint filter + if sprintPath != "" && sprintPath != "all" { + query += fmt.Sprintf(` + AND [System.IterationPath] = '%s'`, escapeWIQL(sprintPath)) + } + + // Add state filter + if state != "" && state != "all" { + query += fmt.Sprintf(` + AND [System.State] = '%s'`, escapeWIQL(state)) + } + + // Add assigned filter + if assigned == "me" { + query += ` + AND [System.AssignedTo] = @me` + } + + // Add area filter + if areaPath != "" && areaPath != "all" { + // Clean up the path + areaPath = strings.TrimPrefix(areaPath, "\\") + areaPath = strings.TrimSuffix(areaPath, "\\") + query += fmt.Sprintf(` + AND [System.AreaPath] UNDER '%s'`, escapeWIQL(areaPath)) + } + + query += ` +ORDER BY [System.ChangedDate] DESC` + + // Execute WIQL query + reqBody := wiqlRequest{Query: query} + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshaling WIQL request: %w", err) + } + + resp, err := c.post("/wit/wiql", bytes.NewReader(bodyBytes)) + if err != nil { + return nil, err + } + + var wiqlResp wiqlResponse + if err := decode(resp, &wiqlResp); err != nil { + return nil, err + } + + if len(wiqlResp.WorkItems) == 0 { + return []models.WorkItem{}, nil + } + + // Get the IDs + ids := make([]string, 0, len(wiqlResp.WorkItems)) + for _, wi := range wiqlResp.WorkItems { + ids = append(ids, fmt.Sprintf("%d", wi.ID)) + } + + // Fetch the full work items + return c.GetWorkItems(ids) +} + +// GetWorkItems fetches multiple work items by ID +func (c *Client) GetWorkItems(ids []string) ([]models.WorkItem, error) { + if len(ids) == 0 { + return []models.WorkItem{}, nil + } + + // API has a limit of 200 items per request + const batchSize = 200 + var allItems []models.WorkItem + + for i := 0; i < len(ids); i += batchSize { + end := i + batchSize + if end > len(ids) { + end = len(ids) + } + + batch := ids[i:end] + fields := "System.Id,System.Title,System.State,System.WorkItemType,System.AssignedTo,System.IterationPath,System.AreaPath,System.Description,System.Tags,System.Parent,Microsoft.VSTS.Common.Priority,System.CreatedDate,System.ChangedDate" + + endpoint := fmt.Sprintf("/wit/workitems?ids=%s&fields=%s", strings.Join(batch, ","), fields) + resp, err := c.get(endpoint) + if err != nil { + return nil, err + } + + var apiResp workItemsResponse + if err := decode(resp, &apiResp); err != nil { + return nil, err + } + + for _, item := range apiResp.Value { + wi := c.convertWorkItem(item) + allItems = append(allItems, wi) + } + } + + return allItems, nil +} + +// GetWorkItem fetches a single work item by ID +func (c *Client) GetWorkItem(id int) (*models.WorkItem, error) { + fields := "System.Id,System.Title,System.State,System.WorkItemType,System.AssignedTo,System.IterationPath,System.AreaPath,System.Description,System.Tags,System.Parent,Microsoft.VSTS.Common.Priority,System.CreatedDate,System.ChangedDate" + + endpoint := fmt.Sprintf("/wit/workitems/%d?fields=%s", id, fields) + resp, err := c.get(endpoint) + if err != nil { + return nil, err + } + + var item workItemAPIItem + if err := decode(resp, &item); err != nil { + return nil, err + } + + wi := c.convertWorkItem(item) + return &wi, nil +} + +// convertWorkItem converts an API work item to our model +func (c *Client) convertWorkItem(item workItemAPIItem) models.WorkItem { + wi := models.WorkItem{ + ID: item.ID, + Rev: item.Rev, + Title: item.Fields.Title, + State: models.WorkItemState(item.Fields.State), + Type: models.WorkItemType(item.Fields.WorkItemType), + IterationPath: item.Fields.IterationPath, + AreaPath: item.Fields.AreaPath, + Description: stripHTML(item.Fields.Description), + ParentID: item.Fields.Parent, + Priority: item.Fields.Priority, + CreatedDate: item.Fields.CreatedDate, + ChangedDate: item.Fields.ChangedDate, + URL: item.URL, + WebURL: c.WorkItemWebURL(item.ID), + } + + if item.Fields.AssignedTo != nil { + wi.AssignedTo = item.Fields.AssignedTo.DisplayName + } + + // Parse tags + if item.Fields.Tags != "" { + tags := strings.Split(item.Fields.Tags, ";") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag != "" { + wi.Tags = append(wi.Tags, tag) + } + } + } + + return wi +} + +// UpdateWorkItemState updates a work item's state +func (c *Client) UpdateWorkItemState(id int, newState string) error { + // Azure DevOps uses JSON Patch format + patchDoc := []map[string]interface{}{ + { + "op": "add", + "path": "/fields/System.State", + "value": newState, + }, + } + + bodyBytes, err := json.Marshal(patchDoc) + if err != nil { + return fmt.Errorf("marshaling patch document: %w", err) + } + + endpoint := fmt.Sprintf("/wit/workitems/%d", id) + resp, err := c.patch(endpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return err + } + resp.Body.Close() + + return nil +} + +// stripHTML removes HTML tags from a string +func stripHTML(s string) string { + // Simple HTML tag removal + re := regexp.MustCompile(`<[^>]*>`) + s = re.ReplaceAllString(s, "") + + // Replace common HTML entities + s = strings.ReplaceAll(s, " ", " ") + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, """, "\"") + s = strings.ReplaceAll(s, "'", "'") + + // Trim whitespace + s = strings.TrimSpace(s) + + return s +} diff --git a/internal/api/workitemtypes.go b/internal/api/workitemtypes.go new file mode 100644 index 0000000..f67e2d0 --- /dev/null +++ b/internal/api/workitemtypes.go @@ -0,0 +1,101 @@ +package api + +import ( + "fmt" + + "github.com/samuelenocsson/devops-tui/internal/models" +) + +// workItemTypesResponse represents the API response for work item types +type workItemTypesResponse struct { + Count int `json:"count"` + Value []workItemTypeAPIItem `json:"value"` +} + +type workItemTypeAPIItem struct { + Name string `json:"name"` + ReferenceName string `json:"referenceName"` + Description string `json:"description"` + Color string `json:"color"` + Icon struct { + ID string `json:"id"` + URL string `json:"url"` + } `json:"icon"` +} + +// statesResponse represents the API response for work item states +type statesResponse struct { + Count int `json:"count"` + Value []stateAPIItem `json:"value"` +} + +type stateAPIItem struct { + Name string `json:"name"` + Color string `json:"color"` + Category string `json:"stateCategory"` +} + +// GetWorkItemTypes fetches all work item types for the project +func (c *Client) GetWorkItemTypes() ([]string, error) { + resp, err := c.get("/wit/workitemtypes") + if err != nil { + return nil, err + } + + var apiResp workItemTypesResponse + if err := decode(resp, &apiResp); err != nil { + return nil, err + } + + types := make([]string, 0, apiResp.Count) + for _, item := range apiResp.Value { + types = append(types, item.Name) + } + + return types, nil +} + +// GetWorkItemTypeStates fetches all states for a specific work item type +func (c *Client) GetWorkItemTypeStates(workItemType string) ([]models.WorkItemStateInfo, error) { + endpoint := fmt.Sprintf("/wit/workitemtypes/%s/states", workItemType) + resp, err := c.get(endpoint) + if err != nil { + return nil, err + } + + var apiResp statesResponse + if err := decode(resp, &apiResp); err != nil { + return nil, err + } + + states := make([]models.WorkItemStateInfo, 0, apiResp.Count) + for _, item := range apiResp.Value { + states = append(states, models.WorkItemStateInfo{ + Name: item.Name, + Color: item.Color, + Category: item.Category, + }) + } + + return states, nil +} + +// GetAllWorkItemTypeStates fetches states for all work item types +func (c *Client) GetAllWorkItemTypeStates() (map[string][]models.WorkItemStateInfo, error) { + types, err := c.GetWorkItemTypes() + if err != nil { + return nil, err + } + + statesByType := make(map[string][]models.WorkItemStateInfo) + for _, t := range types { + states, err := c.GetWorkItemTypeStates(t) + if err != nil { + // Skip types that fail (some system types may not have states) + continue + } + statesByType[t] = states + } + + return statesByType, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ae9f08a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,142 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/viper" +) + +// Config holds the application configuration +type Config struct { + Organization string `mapstructure:"organization"` + Project string `mapstructure:"project"` + Team string `mapstructure:"team"` + PAT string `mapstructure:"pat"` + Theme string `mapstructure:"theme"` + Defaults Defaults `mapstructure:"defaults"` +} + +// Defaults holds default filter settings +type Defaults struct { + Sprint string `mapstructure:"sprint"` + State string `mapstructure:"state"` + Assigned string `mapstructure:"assigned"` +} + +// Load loads the configuration from file and environment +func Load() (*Config, error) { + v := viper.New() + + // Set config file name and paths + v.SetConfigName("config") + v.SetConfigType("yaml") + + // Add config paths + home, err := os.UserHomeDir() + if err == nil { + v.AddConfigPath(filepath.Join(home, ".config", "devops-tui")) + } + v.AddConfigPath(".") + + // Set defaults + v.SetDefault("theme", "default") + v.SetDefault("defaults.sprint", "current") + v.SetDefault("defaults.state", "all") + v.SetDefault("defaults.assigned", "me") + + // Read config file (ignore if not found) + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("error reading config file: %w", err) + } + } + + // Bind environment variables + v.SetEnvPrefix("") + v.AutomaticEnv() + + // Map environment variables + v.BindEnv("pat", "AZURE_DEVOPS_PAT") + v.BindEnv("organization", "AZURE_DEVOPS_ORG") + v.BindEnv("project", "AZURE_DEVOPS_PROJECT") + v.BindEnv("team", "AZURE_DEVOPS_TEAM") + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("error unmarshaling config: %w", err) + } + + // Validate required fields + if cfg.Organization == "" { + return nil, fmt.Errorf("organization is required (set in config or AZURE_DEVOPS_ORG)") + } + if cfg.Project == "" { + return nil, fmt.Errorf("project is required (set in config or AZURE_DEVOPS_PROJECT)") + } + if cfg.Team == "" { + return nil, fmt.Errorf("team is required (set in config or AZURE_DEVOPS_TEAM)") + } + if cfg.PAT == "" { + return nil, fmt.Errorf("PAT is required (set in config or AZURE_DEVOPS_PAT)") + } + + return &cfg, nil +} + +// BaseURL returns the Azure DevOps API base URL +func (c *Config) BaseURL() string { + return fmt.Sprintf("https://dev.azure.com/%s/%s/_apis", c.Organization, c.Project) +} + +// TeamURL returns the Azure DevOps API URL for team-specific endpoints +func (c *Config) TeamURL() string { + return fmt.Sprintf("https://dev.azure.com/%s/%s/%s/_apis", c.Organization, c.Project, c.Team) +} + +// WebURL returns the Azure DevOps web URL for the project +func (c *Config) WebURL() string { + return fmt.Sprintf("https://dev.azure.com/%s/%s", c.Organization, c.Project) +} + +// CreateDefaultConfig creates a default config file +func CreateDefaultConfig() error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + configDir := filepath.Join(home, ".config", "devops-tui") + if err := os.MkdirAll(configDir, 0755); err != nil { + return err + } + + configPath := filepath.Join(configDir, "config.yaml") + + // Don't overwrite existing config + if _, err := os.Stat(configPath); err == nil { + return nil + } + + content := `# Azure DevOps connection +organization: "my-organization" +project: "my-project" +team: "my-team" + +# Authentication +# PAT can be set here or via environment variable AZURE_DEVOPS_PAT +pat: "" + +# UI settings +theme: "default" # default, dark, light + +# Default filters at startup +defaults: + sprint: "current" # "current", "all", or specific name + state: "all" # "all", "new", "active", "resolved", "closed" + assigned: "me" # "all", "me" +` + + return os.WriteFile(configPath, []byte(content), 0600) +} diff --git a/internal/config/state.go b/internal/config/state.go new file mode 100644 index 0000000..b52a99a --- /dev/null +++ b/internal/config/state.go @@ -0,0 +1,80 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// FilterState holds the persisted filter selections +type FilterState struct { + Sprint string `json:"sprint"` + State string `json:"state"` + Assigned string `json:"assigned"` + Area string `json:"area"` +} + +// getStatePath returns the path to the state file +func getStatePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "devops-tui", "state.json"), nil +} + +// LoadFilterState loads the persisted filter state +func LoadFilterState() (*FilterState, error) { + statePath, err := getStatePath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(statePath) + if err != nil { + if os.IsNotExist(err) { + // Return default state if file doesn't exist + return &FilterState{ + Sprint: "current", + State: "all", + Assigned: "me", + Area: "all", + }, nil + } + return nil, err + } + + var state FilterState + if err := json.Unmarshal(data, &state); err != nil { + // Return default state if file is corrupted + return &FilterState{ + Sprint: "current", + State: "all", + Assigned: "me", + Area: "all", + }, nil + } + + return &state, nil +} + +// SaveFilterState saves the filter state to disk +func SaveFilterState(state *FilterState) error { + statePath, err := getStatePath() + if err != nil { + return err + } + + // Ensure directory exists + dir := filepath.Dir(statePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + return os.WriteFile(statePath, data, 0600) +} diff --git a/internal/models/area.go b/internal/models/area.go new file mode 100644 index 0000000..e2ed66e --- /dev/null +++ b/internal/models/area.go @@ -0,0 +1,25 @@ +package models + +import "strings" + +// Area represents an Azure DevOps area +type Area struct { + ID int `json:"id"` + Name string `json:"name"` + Path string `json:"path"` +} + +// DisplayName returns a shortened display name +func (a Area) DisplayName() string { + parts := strings.Split(a.Path, "\\") + if len(parts) > 1 { + // Return the last part for brevity + return parts[len(parts)-1] + } + return a.Name +} + +// FullPath returns the full path +func (a Area) FullPath() string { + return a.Path +} diff --git a/internal/models/filter.go b/internal/models/filter.go new file mode 100644 index 0000000..f0cfd37 --- /dev/null +++ b/internal/models/filter.go @@ -0,0 +1,317 @@ +package models + +// FilterType represents the type of filter +type FilterType int + +const ( + FilterTypeSprint FilterType = iota + FilterTypeState + FilterTypeAssigned + FilterTypeArea +) + +// FilterOption represents a selectable filter option +type FilterOption struct { + Label string + Value string + Selected bool +} + +// FilterGroup represents a group of filter options +type FilterGroup struct { + Type FilterType + Title string + Options []FilterOption + Cursor int + Offset int // Scroll offset for viewing +} + +// SelectedOption returns the currently selected option +func (f *FilterGroup) SelectedOption() *FilterOption { + for i := range f.Options { + if f.Options[i].Selected { + return &f.Options[i] + } + } + if len(f.Options) > 0 { + return &f.Options[0] + } + return nil +} + +// Select marks the option at the given index as selected +func (f *FilterGroup) Select(index int) { + if index < 0 || index >= len(f.Options) { + return + } + for i := range f.Options { + f.Options[i].Selected = i == index + } +} + +// SelectCurrent marks the option at the current cursor as selected +func (f *FilterGroup) SelectCurrent() { + f.Select(f.Cursor) +} + +// MoveUp moves the cursor up +func (f *FilterGroup) MoveUp() { + if f.Cursor > 0 { + f.Cursor-- + } +} + +// MoveDown moves the cursor down +func (f *FilterGroup) MoveDown() { + if f.Cursor < len(f.Options)-1 { + f.Cursor++ + } +} + +// MoveToTop moves cursor to the first option +func (f *FilterGroup) MoveToTop() { + f.Cursor = 0 +} + +// MoveToBottom moves cursor to the last option +func (f *FilterGroup) MoveToBottom() { + if len(f.Options) > 0 { + f.Cursor = len(f.Options) - 1 + } +} + +// FilterState holds the complete filter state +type FilterState struct { + Groups []*FilterGroup + ActiveGroup int + SearchQuery string +} + +// NewFilterState creates a new filter state with default groups +func NewFilterState(iterations []Iteration, areas []Area, statesByType map[string][]WorkItemStateInfo) *FilterState { + // Build sprint options from iterations + sprintOptions := []FilterOption{ + {Label: "All", Value: "all", Selected: false}, + } + + for _, iter := range iterations { + selected := iter.IsCurrent() + sprintOptions = append(sprintOptions, FilterOption{ + Label: iter.DisplayName(), + Value: iter.Path, + Selected: selected, + }) + } + + // If no current sprint found, select "All" + hasSelected := false + for _, opt := range sprintOptions { + if opt.Selected { + hasSelected = true + break + } + } + if !hasSelected && len(sprintOptions) > 0 { + sprintOptions[0].Selected = true + } + + // Build area options from areas + areaOptions := []FilterOption{ + {Label: "All", Value: "all", Selected: true}, + } + for _, area := range areas { + areaOptions = append(areaOptions, FilterOption{ + Label: area.DisplayName(), + Value: area.Path, + Selected: false, + }) + } + + // Build state options from all work item types (unique states) + stateOptions := []FilterOption{ + {Label: "All", Value: "all", Selected: true}, + } + if len(statesByType) > 0 { + // Collect unique states preserving order by category + seenStates := make(map[string]bool) + // Process in category order: Proposed, InProgress, Resolved, Completed + categoryOrder := []string{"Proposed", "InProgress", "Resolved", "Completed", "Removed"} + for _, category := range categoryOrder { + for _, states := range statesByType { + for _, state := range states { + if state.Category == category && !seenStates[state.Name] { + seenStates[state.Name] = true + stateOptions = append(stateOptions, FilterOption{ + Label: state.Name, + Value: state.Name, + Selected: false, + }) + } + } + } + } + // Also add any states that didn't match known categories + for _, states := range statesByType { + for _, state := range states { + if !seenStates[state.Name] { + seenStates[state.Name] = true + stateOptions = append(stateOptions, FilterOption{ + Label: state.Name, + Value: state.Name, + Selected: false, + }) + } + } + } + } + // Fallback to default states if we only have "All" + if len(stateOptions) <= 1 { + defaultStates := []string{"New", "Active", "Resolved", "Closed"} + for _, state := range defaultStates { + stateOptions = append(stateOptions, FilterOption{ + Label: state, + Value: state, + Selected: false, + }) + } + } + + return &FilterState{ + Groups: []*FilterGroup{ + { + Type: FilterTypeSprint, + Title: "Sprint", + Options: sprintOptions, + Cursor: 0, + }, + { + Type: FilterTypeState, + Title: "State", + Options: stateOptions, + Cursor: 0, + }, + { + Type: FilterTypeAssigned, + Title: "Assigned", + Options: []FilterOption{ + {Label: "All", Value: "all", Selected: false}, + {Label: "Me", Value: "me", Selected: true}, + }, + Cursor: 0, + }, + { + Type: FilterTypeArea, + Title: "Area", + Options: areaOptions, + Cursor: 0, + }, + }, + ActiveGroup: 0, + } +} + +// ActiveFilterGroup returns the currently active filter group +func (f *FilterState) ActiveFilterGroup() *FilterGroup { + if f.ActiveGroup >= 0 && f.ActiveGroup < len(f.Groups) { + return f.Groups[f.ActiveGroup] + } + return nil +} + +// NextGroup moves to the next filter group +func (f *FilterState) NextGroup() { + if f.ActiveGroup < len(f.Groups)-1 { + f.ActiveGroup++ + } +} + +// PrevGroup moves to the previous filter group +func (f *FilterState) PrevGroup() { + if f.ActiveGroup > 0 { + f.ActiveGroup-- + } +} + +// GetSelectedSprint returns the selected sprint path +func (f *FilterState) GetSelectedSprint() string { + for _, g := range f.Groups { + if g.Type == FilterTypeSprint { + if opt := g.SelectedOption(); opt != nil { + return opt.Value + } + } + } + return "all" +} + +// GetSelectedState returns the selected state filter +func (f *FilterState) GetSelectedState() string { + for _, g := range f.Groups { + if g.Type == FilterTypeState { + if opt := g.SelectedOption(); opt != nil { + return opt.Value + } + } + } + return "all" +} + +// GetSelectedAssigned returns the selected assigned filter +func (f *FilterState) GetSelectedAssigned() string { + for _, g := range f.Groups { + if g.Type == FilterTypeAssigned { + if opt := g.SelectedOption(); opt != nil { + return opt.Value + } + } + } + return "all" +} + +// GetSelectedArea returns the selected area path +func (f *FilterState) GetSelectedArea() string { + for _, g := range f.Groups { + if g.Type == FilterTypeArea { + if opt := g.SelectedOption(); opt != nil { + return opt.Value + } + } + } + return "all" +} + +// ApplySavedSelections applies saved filter selections +func (f *FilterState) ApplySavedSelections(sprint, state, assigned, area string) { + for _, g := range f.Groups { + var targetValue string + switch g.Type { + case FilterTypeSprint: + targetValue = sprint + case FilterTypeState: + targetValue = state + case FilterTypeAssigned: + targetValue = assigned + case FilterTypeArea: + targetValue = area + } + + if targetValue == "" { + continue + } + + // Find and select the matching option + found := false + for i, opt := range g.Options { + if opt.Value == targetValue { + g.Select(i) + found = true + break + } + } + + // If not found and it's sprint with "current", keep the current selection + if !found && g.Type == FilterTypeSprint && targetValue == "current" { + // Already handled in NewFilterState + } + } +} diff --git a/internal/models/iteration.go b/internal/models/iteration.go new file mode 100644 index 0000000..9922677 --- /dev/null +++ b/internal/models/iteration.go @@ -0,0 +1,37 @@ +package models + +import "time" + +// Iteration represents an Azure DevOps iteration (sprint) +type Iteration struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + StartDate time.Time `json:"startDate"` + FinishDate time.Time `json:"finishDate"` + TimeFrame string `json:"timeFrame"` // "past", "current", "future" + URL string `json:"url"` +} + +// IsCurrent returns true if this is the current iteration +func (i *Iteration) IsCurrent() bool { + return i.TimeFrame == "current" +} + +// IsPast returns true if this iteration is in the past +func (i *Iteration) IsPast() bool { + return i.TimeFrame == "past" +} + +// IsFuture returns true if this iteration is in the future +func (i *Iteration) IsFuture() bool { + return i.TimeFrame == "future" +} + +// DisplayName returns a formatted name for display +func (i *Iteration) DisplayName() string { + if i.IsCurrent() { + return i.Name + " (current)" + } + return i.Name +} diff --git a/internal/models/workitem.go b/internal/models/workitem.go new file mode 100644 index 0000000..413fe4b --- /dev/null +++ b/internal/models/workitem.go @@ -0,0 +1,92 @@ +package models + +import "time" + +// WorkItemType represents the type of work item +type WorkItemType string + +const ( + WorkItemTypeStory WorkItemType = "User Story" + WorkItemTypeTask WorkItemType = "Task" + WorkItemTypeBug WorkItemType = "Bug" + WorkItemTypeFeature WorkItemType = "Feature" + WorkItemTypeEpic WorkItemType = "Epic" +) + +// WorkItemState represents the state of a work item +type WorkItemState string + +const ( + WorkItemStateNew WorkItemState = "New" + WorkItemStateActive WorkItemState = "Active" + WorkItemStateResolved WorkItemState = "Resolved" + WorkItemStateClosed WorkItemState = "Closed" +) + +// WorkItem represents an Azure DevOps work item +type WorkItem struct { + ID int `json:"id"` + Rev int `json:"rev"` + Title string `json:"title"` + State WorkItemState `json:"state"` + Type WorkItemType `json:"type"` + AssignedTo string `json:"assignedTo"` + IterationPath string `json:"iterationPath"` + AreaPath string `json:"areaPath"` + Description string `json:"description"` + Tags []string `json:"tags"` + ParentID int `json:"parentId"` + ParentTitle string `json:"parentTitle"` + Priority int `json:"priority"` + CreatedDate time.Time `json:"createdDate"` + ChangedDate time.Time `json:"changedDate"` + URL string `json:"url"` + WebURL string `json:"webUrl"` +} + +// ShortType returns a short version of the work item type +func (w *WorkItem) ShortType() string { + switch w.Type { + case WorkItemTypeStory: + return "Story" + case WorkItemTypeTask: + return "Task" + case WorkItemTypeBug: + return "Bug" + case WorkItemTypeFeature: + return "Feature" + case WorkItemTypeEpic: + return "Epic" + default: + return string(w.Type) + } +} + +// SprintName extracts the sprint name from the iteration path +func (w *WorkItem) SprintName() string { + // IterationPath is like "MyProject\\Sprint 42" + // Return just "Sprint 42" + for i := len(w.IterationPath) - 1; i >= 0; i-- { + if w.IterationPath[i] == '\\' { + return w.IterationPath[i+1:] + } + } + return w.IterationPath +} + +// AreaName extracts the area name from the area path +func (w *WorkItem) AreaName() string { + for i := len(w.AreaPath) - 1; i >= 0; i-- { + if w.AreaPath[i] == '\\' { + return w.AreaPath[i+1:] + } + } + return w.AreaPath +} + +// WorkItemStateInfo represents state metadata from Azure DevOps +type WorkItemStateInfo struct { + Name string `json:"name"` + Color string `json:"color"` + Category string `json:"category"` // Proposed, InProgress, Resolved, Completed, Removed +} diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..d1b7664 --- /dev/null +++ b/internal/ui/app.go @@ -0,0 +1,532 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/samuelenocsson/devops-tui/internal/api" + "github.com/samuelenocsson/devops-tui/internal/config" + "github.com/samuelenocsson/devops-tui/internal/models" + "github.com/samuelenocsson/devops-tui/internal/ui/components" + "github.com/samuelenocsson/devops-tui/internal/ui/theme" + "github.com/samuelenocsson/devops-tui/pkg/browser" + "github.com/samuelenocsson/devops-tui/pkg/git" +) + +// Panel represents the active panel +type Panel int + +const ( + PanelFilter Panel = iota + PanelWorkItems +) + +// ViewMode represents the current view mode +type ViewMode int + +const ( + ViewMain ViewMode = iota + ViewDetail +) + +// App is the main application model +type App struct { + // Components + filterPanel components.FilterPanel + workItemsPanel components.WorkItemsPanel + detailsPanel components.DetailsPanel + detailView components.DetailView + helpPanel components.HelpPanel + stateModal components.StateModal + branchModal components.BranchModal + + // State + activePanel Panel + viewMode ViewMode + loading bool + err error + statusMsg string // Temporary status message + + // Data + iterations []models.Iteration + areas []models.Area + workItems []models.WorkItem + statesByType map[string][]models.WorkItemStateInfo + + // Services + client *api.Client + + // Config + styles theme.Styles + keys theme.KeyMap + + // Dimensions + width int + height int +} + +// NewApp creates a new application +func NewApp(client *api.Client) App { + styles := theme.DefaultStyles() + keys := theme.DefaultKeyMap() + + // Create empty filter state (will be populated after loading data) + filterState := models.NewFilterState(nil, nil, nil) + + return App{ + filterPanel: components.NewFilterPanel(filterState, styles, keys), + workItemsPanel: components.NewWorkItemsPanel(styles, keys), + detailsPanel: components.NewDetailsPanel(styles), + detailView: components.NewDetailView(styles, keys), + helpPanel: components.NewHelpPanel(keys, styles), + stateModal: components.NewStateModal(styles, keys), + branchModal: components.NewBranchModal(styles, keys), + activePanel: PanelWorkItems, + viewMode: ViewMain, + loading: true, + client: client, + styles: styles, + keys: keys, + } +} + +// Init initializes the application +func (a App) Init() tea.Cmd { + return tea.Batch( + loadDataCmd(a.client), + ) +} + +// Update handles messages +func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + a.width = msg.Width + a.height = msg.Height + a.updateSizes() + + case tea.KeyMsg: + // Handle modals first (they capture all input when visible) + if a.stateModal.IsVisible() { + newModal, cmd := a.stateModal.Update(msg) + a.stateModal = newModal + if cmd != nil { + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...) + } + + if a.branchModal.IsVisible() { + newModal, cmd := a.branchModal.Update(msg) + a.branchModal = newModal + if cmd != nil { + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...) + } + + // Global keys + if key.Matches(msg, a.keys.Quit) && !a.helpPanel.IsVisible() && a.viewMode == ViewMain { + return a, tea.Quit + } + + if key.Matches(msg, a.keys.Help) { + a.helpPanel.Toggle() + return a, nil + } + + // Close help with any key if visible + if a.helpPanel.IsVisible() { + if key.Matches(msg, a.keys.Back) || key.Matches(msg, a.keys.Help) { + a.helpPanel.SetVisible(false) + } + return a, nil + } + + // Handle detail view mode + if a.viewMode == ViewDetail { + newDetailView, cmd := a.detailView.Update(msg) + a.detailView = newDetailView + if cmd != nil { + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...) + } + + // Panel switching + if key.Matches(msg, a.keys.NextPanel) { + a.nextPanel() + a.updateFocus() + } + if key.Matches(msg, a.keys.PrevPanel) { + a.prevPanel() + a.updateFocus() + } + + // Refresh + if key.Matches(msg, a.keys.Refresh) { + a.loading = true + a.statusMsg = "" + return a, loadWorkItemsCmd(a.client, a.filterPanel.FilterState()) + } + + // Open state change modal (only when work items panel is active) + if key.Matches(msg, a.keys.ChangeState) && a.activePanel == PanelWorkItems { + if item := a.workItemsPanel.SelectedItem(); item != nil { + a.stateModal.SetItem(item) + a.stateModal.SetSize(a.width, a.height) + a.stateModal.SetVisible(true) + return a, nil + } + } + + // Open branch modal (only when work items panel is active) + if key.Matches(msg, a.keys.CreateBranch) && a.activePanel == PanelWorkItems { + if item := a.workItemsPanel.SelectedItem(); item != nil { + a.branchModal.SetItem(item) + a.branchModal.SetSize(a.width, a.height) + a.branchModal.SetVisible(true) + return a, nil + } + } + + // Update active panel + switch a.activePanel { + case PanelFilter: + newFilter, cmd := a.filterPanel.Update(msg) + a.filterPanel = newFilter + if cmd != nil { + cmds = append(cmds, cmd) + } + case PanelWorkItems: + newWorkItems, cmd := a.workItemsPanel.Update(msg) + a.workItemsPanel = newWorkItems + if cmd != nil { + cmds = append(cmds, cmd) + } + } + + case dataLoadedMsg: + a.iterations = msg.iterations + a.areas = msg.areas + a.statesByType = msg.statesByType + a.stateModal.SetStatesByType(a.statesByType) + filterState := models.NewFilterState(a.iterations, a.areas, a.statesByType) + + // Apply saved filter selections + if savedState, err := config.LoadFilterState(); err == nil { + filterState.ApplySavedSelections(savedState.Sprint, savedState.State, savedState.Assigned, savedState.Area) + } + + a.filterPanel.SetFilterState(filterState) + // Load work items with initial filters + return a, loadWorkItemsCmd(a.client, filterState) + + case workItemsLoadedMsg: + a.loading = false + a.workItems = msg.items + a.workItemsPanel.SetItems(msg.items) + a.updateSelectedItem() + + case components.FilterChangedMsg: + a.loading = true + fs := a.filterPanel.FilterState() + + // Save filter selections for next startup + _ = config.SaveFilterState(&config.FilterState{ + Sprint: fs.GetSelectedSprint(), + State: fs.GetSelectedState(), + Assigned: fs.GetSelectedAssigned(), + Area: fs.GetSelectedArea(), + }) + + return a, loadWorkItemsCmd(a.client, fs) + + case components.OpenWorkItemMsg: + if err := browser.Open(msg.Item.WebURL); err != nil { + a.err = err + } + + case components.ViewWorkItemMsg: + a.viewMode = ViewDetail + a.detailView.SetItem(&msg.Item) + a.updateSizes() + + case components.CloseDetailViewMsg: + a.viewMode = ViewMain + + case errMsg: + a.loading = false + a.err = msg.err + + case components.ModalClosedMsg: + // Modal was closed, nothing special to do + a.stateModal.SetVisible(false) + a.branchModal.SetVisible(false) + + case components.StateChangeRequestMsg: + a.stateModal.SetVisible(false) + a.loading = true + return a, updateWorkItemStateCmd(a.client, msg.Item.ID, msg.NewState, a.filterPanel.FilterState()) + + case stateChangedMsg: + a.loading = false + a.statusMsg = fmt.Sprintf("State changed to %s", msg.newState) + // Refresh work items to show updated state + return a, loadWorkItemsCmd(a.client, a.filterPanel.FilterState()) + + case components.BranchCreateRequestMsg: + a.branchModal.SetVisible(false) + return a, createBranchCmd(msg.BranchName) + + case components.BranchCreatedMsg: + a.statusMsg = fmt.Sprintf("Branch created: %s", msg.BranchName) + + case components.BranchCreateErrorMsg: + a.err = msg.Err + } + + // Update selected item in details panel + a.updateSelectedItem() + + return a, tea.Batch(cmds...) +} + +// View renders the application +func (a App) View() string { + if a.width == 0 || a.height == 0 { + return "Loading..." + } + + // Render state modal if visible + if a.stateModal.IsVisible() { + return a.stateModal.View() + } + + // Render branch modal if visible + if a.branchModal.IsVisible() { + return a.branchModal.View() + } + + // Render help overlay if visible + if a.helpPanel.IsVisible() { + _ = a.renderMainView() + help := a.helpPanel.View() + return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, help) + } + + // Render detail view if in detail mode + if a.viewMode == ViewDetail { + return a.detailView.View() + } + + return a.renderMainView() +} + +func (a *App) renderMainView() string { + // Calculate dimensions + // Borders add 2 chars per panel (1 left + 1 right) + // Two panels side by side = 4 total border overhead + filterWidth := int(float64(a.width) * 0.20) + if filterWidth < 20 { + filterWidth = 20 + } + contentWidth := a.width - filterWidth - 4 + + // Available height: total - title bar (1) - status bar (1) = a.height - 2 + // Filter panel: content height + border (2) = available height + availableHeight := a.height - 2 + filterContentHeight := availableHeight - 2 + + // Right side has two panels stacked, each with border (2 each = 4 total) + rightContentHeight := availableHeight - 4 + workItemsHeight := int(float64(rightContentHeight) * 0.55) + if workItemsHeight < 8 { + workItemsHeight = 8 + } + detailsHeight := rightContentHeight - workItemsHeight + + // Title bar + title := a.styles.Title.Render("devops-tui") + projectInfo := a.styles.Subtitle.Render(fmt.Sprintf("%s/%s", a.client.Organization(), a.client.Project())) + titleBar := lipgloss.JoinHorizontal(lipgloss.Left, title, " ", projectInfo) + + // Loading indicator + if a.loading { + titleBar += " " + a.styles.Subtitle.Render("Loading...") + } + + // Error display + if a.err != nil { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444")) + titleBar += " " + errStyle.Render(a.err.Error()) + } + + // Status message + if a.statusMsg != "" { + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")) + titleBar += " " + statusStyle.Render(a.statusMsg) + } + + // Set panel sizes (content dimensions, borders added by styles) + a.filterPanel.SetSize(filterWidth, filterContentHeight) + a.workItemsPanel.SetSize(contentWidth, workItemsHeight) + a.detailsPanel.SetSize(contentWidth, detailsHeight) + + // Render panels + filterView := a.filterPanel.View() + workItemsView := a.workItemsPanel.View() + detailsView := a.detailsPanel.View() + + // Right side (work items + details) + rightSide := lipgloss.JoinVertical(lipgloss.Left, workItemsView, detailsView) + + // Main content + mainContent := lipgloss.JoinHorizontal(lipgloss.Top, filterView, rightSide) + + // Status bar + statusBar := a.renderStatusBar() + + // Combine all + return lipgloss.JoinVertical(lipgloss.Left, + titleBar, + mainContent, + statusBar, + ) +} + +func (a *App) renderStatusBar() string { + var parts []string + + // Panel indicator + panelName := "Filter" + if a.activePanel == PanelWorkItems { + panelName = "Work Items" + } + parts = append(parts, a.styles.HelpKey.Render("Panel")+": "+panelName) + + // Short help + help := components.ShortHelp(a.keys, a.styles) + parts = append(parts, help) + + return a.styles.StatusBar.Width(a.width).Render(strings.Join(parts, " ")) +} + +func (a *App) nextPanel() { + if a.activePanel == PanelFilter { + a.activePanel = PanelWorkItems + } else { + a.activePanel = PanelFilter + } +} + +func (a *App) prevPanel() { + if a.activePanel == PanelWorkItems { + a.activePanel = PanelFilter + } else { + a.activePanel = PanelWorkItems + } +} + +func (a *App) updateFocus() { + a.filterPanel.SetFocused(a.activePanel == PanelFilter) + a.workItemsPanel.SetFocused(a.activePanel == PanelWorkItems) +} + +func (a *App) updateSizes() { + a.helpPanel.SetSize(a.width, a.height) + a.detailView.SetSize(a.width, a.height) + a.updateFocus() +} + +func (a *App) updateSelectedItem() { + item := a.workItemsPanel.SelectedItem() + a.detailsPanel.SetItem(item) +} + +// Message types + +type dataLoadedMsg struct { + iterations []models.Iteration + areas []models.Area + statesByType map[string][]models.WorkItemStateInfo +} + +type workItemsLoadedMsg struct { + items []models.WorkItem +} + +type errMsg struct { + err error +} + +type stateChangedMsg struct { + newState string +} + +// Commands + +func loadDataCmd(client *api.Client) tea.Cmd { + return func() tea.Msg { + iterations, err := client.GetIterations() + if err != nil { + return errMsg{err: err} + } + areas, err := client.GetAreas() + if err != nil { + return errMsg{err: err} + } + statesByType, err := client.GetAllWorkItemTypeStates() + if err != nil { + // Non-fatal - we can still work with hardcoded states + statesByType = make(map[string][]models.WorkItemStateInfo) + } + return dataLoadedMsg{iterations: iterations, areas: areas, statesByType: statesByType} + } +} + +func loadWorkItemsCmd(client *api.Client, filterState *models.FilterState) tea.Cmd { + return func() tea.Msg { + sprint := filterState.GetSelectedSprint() + state := filterState.GetSelectedState() + assigned := filterState.GetSelectedAssigned() + area := filterState.GetSelectedArea() + + items, err := client.QueryWorkItems(sprint, state, assigned, area) + if err != nil { + return errMsg{err: err} + } + return workItemsLoadedMsg{items: items} + } +} + +func updateWorkItemStateCmd(client *api.Client, itemID int, newState string, filterState *models.FilterState) tea.Cmd { + return func() tea.Msg { + err := client.UpdateWorkItemState(itemID, newState) + if err != nil { + return errMsg{err: err} + } + return stateChangedMsg{newState: newState} + } +} + +func createBranchCmd(branchName string) tea.Cmd { + return func() tea.Msg { + if !git.IsGitRepo() { + return components.BranchCreateErrorMsg{Err: fmt.Errorf("not a git repository")} + } + if git.HasUncommittedChanges() { + return components.BranchCreateErrorMsg{Err: fmt.Errorf("uncommitted changes exist")} + } + err := git.CreateBranch(branchName, true) + if err != nil { + return components.BranchCreateErrorMsg{Err: err} + } + return components.BranchCreatedMsg{BranchName: branchName} + } +} diff --git a/internal/ui/components/branchmodal.go b/internal/ui/components/branchmodal.go new file mode 100644 index 0000000..e17f363 --- /dev/null +++ b/internal/ui/components/branchmodal.go @@ -0,0 +1,251 @@ +package components + +import ( + "fmt" + "regexp" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/samuelenocsson/devops-tui/internal/models" + "github.com/samuelenocsson/devops-tui/internal/ui/theme" +) + +// BranchModal is a modal for creating a branch linked to a work item +type BranchModal struct { + visible bool + item *models.WorkItem + textInput textinput.Model + styles theme.Styles + keys theme.KeyMap + width int + height int + err error +} + +// NewBranchModal creates a new branch modal +func NewBranchModal(styles theme.Styles, keys theme.KeyMap) BranchModal { + ti := textinput.New() + ti.Placeholder = "feature/123-task-name" + ti.CharLimit = 100 + ti.Width = 35 + + return BranchModal{ + textInput: ti, + styles: styles, + keys: keys, + } +} + +// Init initializes the modal +func (m BranchModal) Init() tea.Cmd { + return nil +} + +// Update handles messages +func (m BranchModal) Update(msg tea.Msg) (BranchModal, tea.Cmd) { + if !m.visible { + return m, nil + } + + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Back): + m.visible = false + m.err = nil + return m, func() tea.Msg { return ModalClosedMsg{} } + case msg.Type == tea.KeyEnter: + branchName := strings.TrimSpace(m.textInput.Value()) + if branchName == "" { + m.err = fmt.Errorf("branch name cannot be empty") + return m, nil + } + if !isValidBranchName(branchName) { + m.err = fmt.Errorf("invalid branch name") + return m, nil + } + m.err = nil + return m, func() tea.Msg { + return BranchCreateRequestMsg{ + Item: *m.item, + BranchName: branchName, + } + } + default: + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd + } + } + + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +// View renders the modal +func (m BranchModal) View() string { + if !m.visible { + return "" + } + + // Modal dimensions + modalWidth := 50 + modalHeight := 10 + + // Build content + var b strings.Builder + + // Title + title := lipgloss.NewStyle().Bold(true).Render("Create Branch") + b.WriteString(title + "\n") + + // Item info + if m.item != nil { + itemInfo := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#9CA3AF")). + Render("#" + itoa(m.item.ID) + " " + truncateStr(m.item.Title, 35)) + b.WriteString(itemInfo + "\n\n") + } + + // Label + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#D1D5DB")) + b.WriteString(labelStyle.Render("Branch name:") + "\n") + + // Text input + b.WriteString(m.textInput.View() + "\n") + + // Error message + if m.err != nil { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444")) + b.WriteString(errStyle.Render(m.err.Error()) + "\n") + } else { + b.WriteString("\n") + } + + // Help text + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")) + b.WriteString(helpStyle.Render("Enter: create Esc: cancel")) + + // Modal style + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7C3AED")). + Padding(1, 2). + Width(modalWidth). + Height(modalHeight). + Background(lipgloss.Color("#1F2937")) + + modal := modalStyle.Render(b.String()) + + // Center the modal + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal) +} + +// SetVisible sets the visibility +func (m *BranchModal) SetVisible(visible bool) { + m.visible = visible + m.err = nil + if visible { + m.textInput.Focus() + // Generate suggested branch name from work item + if m.item != nil { + suggested := generateBranchName(m.item) + m.textInput.SetValue(suggested) + m.textInput.CursorEnd() + } + } else { + m.textInput.Blur() + } +} + +// IsVisible returns whether the modal is visible +func (m *BranchModal) IsVisible() bool { + return m.visible +} + +// SetItem sets the work item to link +func (m *BranchModal) SetItem(item *models.WorkItem) { + m.item = item +} + +// SetSize sets the modal container size +func (m *BranchModal) SetSize(width, height int) { + m.width = width + m.height = height +} + +// generateBranchName generates a suggested branch name from work item +func generateBranchName(item *models.WorkItem) string { + // Get work item type prefix + prefix := "feature" + switch item.Type { + case models.WorkItemTypeBug: + prefix = "bugfix" + case models.WorkItemTypeTask: + prefix = "task" + case models.WorkItemTypeStory: + prefix = "feature" + case models.WorkItemTypeEpic: + prefix = "epic" + } + + // Sanitize title for branch name + title := strings.ToLower(item.Title) + // Replace spaces and special chars with hyphens + re := regexp.MustCompile(`[^a-z0-9]+`) + title = re.ReplaceAllString(title, "-") + // Remove leading/trailing hyphens + title = strings.Trim(title, "-") + // Truncate to reasonable length + if len(title) > 40 { + title = title[:40] + // Don't cut off in middle of a word if possible + if lastHyphen := strings.LastIndex(title, "-"); lastHyphen > 20 { + title = title[:lastHyphen] + } + } + + return fmt.Sprintf("%s/%d-%s", prefix, item.ID, title) +} + +// isValidBranchName validates branch name +func isValidBranchName(name string) bool { + // Basic validation for git branch names + if name == "" { + return false + } + // Check for invalid characters + invalid := regexp.MustCompile(`[\s~^:?*\[\]\\]`) + if invalid.MatchString(name) { + return false + } + // Can't start or end with / + if strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") { + return false + } + // Can't have consecutive dots + if strings.Contains(name, "..") { + return false + } + return true +} + +// BranchCreateRequestMsg is sent when user confirms branch creation +type BranchCreateRequestMsg struct { + Item models.WorkItem + BranchName string +} + +// BranchCreatedMsg is sent when branch creation is complete +type BranchCreatedMsg struct { + BranchName string +} + +// BranchCreateErrorMsg is sent when branch creation fails +type BranchCreateErrorMsg struct { + Err error +} diff --git a/internal/ui/components/details.go b/internal/ui/components/details.go new file mode 100644 index 0000000..3680048 --- /dev/null +++ b/internal/ui/components/details.go @@ -0,0 +1,212 @@ +package components + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" + "github.com/samuelenocsson/devops-tui/internal/models" + "github.com/samuelenocsson/devops-tui/internal/ui/theme" +) + +// DetailsPanel shows details for a selected work item +type DetailsPanel struct { + item *models.WorkItem + styles theme.Styles + width int + height int + renderedDesc string + renderedDescWidth int +} + +// NewDetailsPanel creates a new details panel +func NewDetailsPanel(styles theme.Styles) DetailsPanel { + return DetailsPanel{ + styles: styles, + } +} + +// View renders the details panel +func (d DetailsPanel) View() string { + if d.item == nil { + content := d.styles.Subtitle.Render("Select a work item to view details") + return d.styles.PanelInactive. + Width(d.width). + Height(d.height). + Render(content) + } + + var b strings.Builder + + // Title + title := fmt.Sprintf("#%d %s", d.item.ID, d.item.Title) + if len(title) > d.width-6 { + title = title[:d.width-9] + "..." + } + b.WriteString(d.styles.DetailTitle.Render(title)) + b.WriteString("\n\n") + + // Metadata section with aligned columns + typeStyle := d.styles.TypeBadge(string(d.item.Type)) + stateStyle := d.styles.StateBadge(string(d.item.State)) + + labelWidth := 10 + valueWidth := 12 + + // Type and State row + b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Type:")) + b.WriteString(typeStyle.Width(valueWidth).Render(d.item.ShortType())) + b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("State:")) + b.WriteString(stateStyle.Render(string(d.item.State))) + b.WriteString("\n") + + // Assigned and Sprint row + assignedTo := d.item.AssignedTo + if assignedTo == "" { + assignedTo = "Unassigned" + } + b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Assigned:")) + b.WriteString(d.styles.DetailValue.Width(valueWidth).Render(truncate(assignedTo, valueWidth))) + b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Sprint:")) + b.WriteString(d.styles.DetailValue.Render(d.item.SprintName())) + b.WriteString("\n") + + // Area row + b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Area:")) + b.WriteString(d.styles.DetailValue.Render(d.item.AreaName())) + b.WriteString("\n") + + // Parent (if exists) + if d.item.ParentID > 0 { + b.WriteString("\n") + parentLabel := fmt.Sprintf("Parent: #%d", d.item.ParentID) + if d.item.ParentTitle != "" { + parentLabel += " " + d.item.ParentTitle + } + b.WriteString(d.styles.Subtitle.Render(parentLabel)) + b.WriteString("\n") + } + + // Description section + if d.item.Description != "" { + b.WriteString("\n") + b.WriteString(d.styles.DetailSectionTitle.Render("─── Description ───")) + b.WriteString("\n") + + maxWidth := d.width - 8 + if maxWidth < 20 { + maxWidth = 20 + } + + // Use cached rendered description if available and width matches + desc := d.renderedDesc + if desc == "" || d.renderedDescWidth != maxWidth { + desc = d.renderMarkdown(d.item.Description, maxWidth) + } + + // Limit description height + lines := strings.Split(desc, "\n") + maxLines := d.height - 12 + if maxLines < 3 { + maxLines = 3 + } + if len(lines) > maxLines { + lines = lines[:maxLines] + lines = append(lines, "...") + } + + b.WriteString(strings.Join(lines, "\n")) + b.WriteString("\n") + } + + // Tags section + if len(d.item.Tags) > 0 { + b.WriteString("\n") + b.WriteString(d.styles.DetailSectionTitle.Render("─── Tags ───")) + b.WriteString("\n") + + var tagStrings []string + for _, tag := range d.item.Tags { + tagStrings = append(tagStrings, d.styles.DetailTag.Render(tag)) + } + b.WriteString(strings.Join(tagStrings, " ")) + } + + content := b.String() + return d.styles.PanelInactive. + Width(d.width). + Height(d.height). + BorderForeground(lipgloss.Color("#374151")). + Render(content) +} + +// SetItem sets the work item to display +func (d *DetailsPanel) SetItem(item *models.WorkItem) { + d.item = item + d.renderedDesc = "" + d.renderedDescWidth = 0 +} + +// renderMarkdown renders markdown content and caches the result +func (d *DetailsPanel) renderMarkdown(content string, width int) string { + renderer, err := glamour.NewTermRenderer( + glamour.WithStylePath("dark"), + glamour.WithWordWrap(width), + ) + + var result string + if err == nil { + rendered, renderErr := renderer.Render(content) + if renderErr == nil { + result = strings.TrimSpace(rendered) + } else { + result = wordWrap(content, width) + } + } else { + result = wordWrap(content, width) + } + + d.renderedDesc = result + d.renderedDescWidth = width + return result +} + +// SetSize sets the size of the details panel +func (d *DetailsPanel) SetSize(width, height int) { + d.width = width + d.height = height +} + +// Helper functions + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +func wordWrap(text string, width int) string { + if width <= 0 { + return text + } + + var result strings.Builder + words := strings.Fields(text) + currentLineLength := 0 + + for i, word := range words { + if currentLineLength+len(word)+1 > width { + result.WriteString("\n") + currentLineLength = 0 + } else if i > 0 { + result.WriteString(" ") + currentLineLength++ + } + result.WriteString(word) + currentLineLength += len(word) + } + + return result.String() +} diff --git a/internal/ui/components/detailview.go b/internal/ui/components/detailview.go new file mode 100644 index 0000000..18daca5 --- /dev/null +++ b/internal/ui/components/detailview.go @@ -0,0 +1,211 @@ +package components + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/samuelenocsson/devops-tui/internal/models" + "github.com/samuelenocsson/devops-tui/internal/ui/theme" +) + +// DetailView is the fullscreen detail view component +type DetailView struct { + item *models.WorkItem + styles theme.Styles + keys theme.KeyMap + width int + height int + scrollOffset int + maxScroll int +} + +// NewDetailView creates a new detail view +func NewDetailView(styles theme.Styles, keys theme.KeyMap) DetailView { + return DetailView{ + styles: styles, + keys: keys, + } +} + +// Init initializes the detail view +func (d DetailView) Init() tea.Cmd { + return nil +} + +// Update handles messages for the detail view +func (d DetailView) Update(msg tea.Msg) (DetailView, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, d.keys.Back): + return d, func() tea.Msg { return CloseDetailViewMsg{} } + case key.Matches(msg, d.keys.Quit) && msg.String() == "q": + return d, func() tea.Msg { return CloseDetailViewMsg{} } + case key.Matches(msg, d.keys.Open): + if d.item != nil { + return d, func() tea.Msg { return OpenWorkItemMsg{Item: *d.item} } + } + case key.Matches(msg, d.keys.Up): + if d.scrollOffset > 0 { + d.scrollOffset-- + } + case key.Matches(msg, d.keys.Down): + if d.scrollOffset < d.maxScroll { + d.scrollOffset++ + } + } + } + + return d, nil +} + +// View renders the detail view +func (d DetailView) View() string { + if d.item == nil { + return "" + } + + var sections []string + + // Title bar + title := fmt.Sprintf("#%d %s", d.item.ID, d.item.Title) + titleBar := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#F9FAFB")). + Background(lipgloss.Color("#7C3AED")). + Padding(0, 1). + Width(d.width - 2). + Render(title) + sections = append(sections, titleBar) + + // Metadata section + metadataContent := d.renderMetadata() + metadataSection := d.styles.DetailSection. + Width(d.width - 6). + Render("METADATA\n" + metadataContent) + sections = append(sections, metadataSection) + + // Parent section (if exists) + if d.item.ParentID > 0 { + parentContent := fmt.Sprintf("#%d", d.item.ParentID) + if d.item.ParentTitle != "" { + parentContent += " " + d.item.ParentTitle + } + parentSection := d.styles.DetailSection. + Width(d.width - 6). + Render("PARENT\n" + parentContent) + sections = append(sections, parentSection) + } + + // Description section + if d.item.Description != "" { + desc := wordWrap(d.item.Description, d.width-10) + descSection := d.styles.DetailSection. + Width(d.width - 6). + Render("DESCRIPTION\n" + desc) + sections = append(sections, descSection) + } + + // Tags section + if len(d.item.Tags) > 0 { + var tagStrings []string + for _, tag := range d.item.Tags { + tagStrings = append(tagStrings, d.styles.DetailTag.Render(tag)) + } + tagsSection := d.styles.DetailSection. + Width(d.width - 6). + Render("TAGS\n" + strings.Join(tagStrings, " ")) + sections = append(sections, tagsSection) + } + + // Join all sections + content := strings.Join(sections, "\n\n") + + // Calculate scrolling + contentLines := strings.Split(content, "\n") + viewableHeight := d.height - 4 + d.maxScroll = len(contentLines) - viewableHeight + if d.maxScroll < 0 { + d.maxScroll = 0 + } + + // Apply scrolling + if d.scrollOffset > 0 && d.scrollOffset < len(contentLines) { + contentLines = contentLines[d.scrollOffset:] + } + if len(contentLines) > viewableHeight { + contentLines = contentLines[:viewableHeight] + } + + scrolledContent := strings.Join(contentLines, "\n") + + // Status bar + statusBar := d.renderStatusBar() + + // Build final view + mainContent := d.styles.PanelActive. + Width(d.width). + Height(d.height - 2). + Render(scrolledContent) + + return lipgloss.JoinVertical( + lipgloss.Left, + mainContent, + statusBar, + ) +} + +func (d *DetailView) renderMetadata() string { + typeStyle := d.styles.TypeBadge(string(d.item.Type)) + stateStyle := d.styles.StateBadge(string(d.item.State)) + + labelWidth := 12 + valueWidth := 20 + + label := func(s string) string { + return d.styles.DetailLabel.Width(labelWidth).Render(s) + } + value := func(s string) string { + return d.styles.DetailValue.Width(valueWidth).Render(s) + } + + rows := []string{ + label("Type:") + typeStyle.Render(d.item.ShortType()) + " " + label("ID:") + value(fmt.Sprintf("#%d", d.item.ID)), + label("State:") + stateStyle.Render(string(d.item.State)) + " " + label("Created:") + value(d.item.CreatedDate.Format("2006-01-02")), + } + + assignedTo := d.item.AssignedTo + if assignedTo == "" { + assignedTo = "Unassigned" + } + rows = append(rows, label("Assigned:")+value(assignedTo)+" "+label("Updated:")+value(d.item.ChangedDate.Format("2006-01-02"))) + rows = append(rows, label("Sprint:")+value(d.item.SprintName())+" "+label("Priority:")+value(fmt.Sprintf("%d", d.item.Priority))) + rows = append(rows, label("Area:")+value(d.item.AreaName())) + + return strings.Join(rows, "\n") +} + +func (d *DetailView) renderStatusBar() string { + help := "Esc Back Enter Open in browser j/k Scroll" + return d.styles.StatusBar. + Width(d.width). + Render(help) +} + +// SetItem sets the work item to display +func (d *DetailView) SetItem(item *models.WorkItem) { + d.item = item + d.scrollOffset = 0 +} + +// SetSize sets the size of the detail view +func (d *DetailView) SetSize(width, height int) { + d.width = width + d.height = height +} + +// CloseDetailViewMsg is sent when the detail view should be closed +type CloseDetailViewMsg struct{} diff --git a/internal/ui/components/filter.go b/internal/ui/components/filter.go new file mode 100644 index 0000000..0e8e1e5 --- /dev/null +++ b/internal/ui/components/filter.go @@ -0,0 +1,223 @@ +package components + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/samuelenocsson/devops-tui/internal/models" + "github.com/samuelenocsson/devops-tui/internal/ui/theme" +) + +const maxVisibleOptions = 6 // Max visible options per filter group + +// FilterPanel is the filter panel component +type FilterPanel struct { + filterState *models.FilterState + styles theme.Styles + keys theme.KeyMap + width int + height int + focused bool +} + +// NewFilterPanel creates a new filter panel +func NewFilterPanel(filterState *models.FilterState, styles theme.Styles, keys theme.KeyMap) FilterPanel { + return FilterPanel{ + filterState: filterState, + styles: styles, + keys: keys, + } +} + +// Init initializes the filter panel +func (f FilterPanel) Init() tea.Cmd { + return nil +} + +// Update handles messages for the filter panel +func (f FilterPanel) Update(msg tea.Msg) (FilterPanel, tea.Cmd) { + if !f.focused { + return f, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, f.keys.Up): + if group := f.filterState.ActiveFilterGroup(); group != nil { + group.MoveUp() + f.adjustOffset(group) + } + case key.Matches(msg, f.keys.Down): + if group := f.filterState.ActiveFilterGroup(); group != nil { + group.MoveDown() + f.adjustOffset(group) + } + case key.Matches(msg, f.keys.Top): + if group := f.filterState.ActiveFilterGroup(); group != nil { + group.MoveToTop() + group.Offset = 0 + } + case key.Matches(msg, f.keys.Bottom): + if group := f.filterState.ActiveFilterGroup(); group != nil { + group.MoveToBottom() + f.adjustOffset(group) + } + case key.Matches(msg, f.keys.Select): + if group := f.filterState.ActiveFilterGroup(); group != nil { + group.SelectCurrent() + } + return f, func() tea.Msg { return FilterChangedMsg{} } + case key.Matches(msg, f.keys.Left): + f.filterState.PrevGroup() + case key.Matches(msg, f.keys.Right): + f.filterState.NextGroup() + } + } + + return f, nil +} + +// adjustOffset ensures the cursor is visible within the scroll window +func (f *FilterPanel) adjustOffset(group *models.FilterGroup) { + if group.Cursor < group.Offset { + group.Offset = group.Cursor + } + if group.Cursor >= group.Offset+maxVisibleOptions { + group.Offset = group.Cursor - maxVisibleOptions + 1 + } +} + +// View renders the filter panel +func (f FilterPanel) View() string { + var b strings.Builder + + for i, group := range f.filterState.Groups { + isActiveGroup := i == f.filterState.ActiveGroup && f.focused + + // Group title with count if scrollable + titleStyle := f.styles.FilterGroupTitle + if isActiveGroup { + titleStyle = titleStyle.Foreground(lipgloss.Color("#7C3AED")) + } + title := group.Title + if len(group.Options) > maxVisibleOptions { + countStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")) + title += countStyle.Render(" (" + itoa(len(group.Options)) + ")") + } + b.WriteString(titleStyle.Render(title)) + b.WriteString("\n") + + // Separator + sep := strings.Repeat("─", min(f.width-4, 15)) + b.WriteString(f.styles.Subtitle.Render(sep)) + b.WriteString("\n") + + // Scroll up indicator + if group.Offset > 0 { + scrollStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")) + b.WriteString(scrollStyle.Render(" β–² more")) + b.WriteString("\n") + } + + // Calculate visible range + startIdx := group.Offset + endIdx := startIdx + maxVisibleOptions + if endIdx > len(group.Options) { + endIdx = len(group.Options) + } + + // Options (only visible ones) + for j := startIdx; j < endIdx; j++ { + opt := group.Options[j] + isCursor := j == group.Cursor && isActiveGroup + + // Selection indicator + var indicator string + if opt.Selected { + indicator = "●" + } else { + indicator = "β—‹" + } + + // Cursor indicator + var cursor string + if isCursor { + cursor = "β–Έ" + } else { + cursor = " " + } + + // Style the option + optionStyle := f.styles.FilterOption + if opt.Selected { + optionStyle = f.styles.FilterSelected + } + if isCursor { + optionStyle = optionStyle.Bold(true).Foreground(lipgloss.Color("#7C3AED")) + } + + line := cursor + " " + indicator + " " + opt.Label + b.WriteString(optionStyle.Render(line)) + b.WriteString("\n") + } + + // Scroll down indicator + if endIdx < len(group.Options) { + scrollStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")) + b.WriteString(scrollStyle.Render(" β–Ό more")) + b.WriteString("\n") + } + + // Add spacing between groups + if i < len(f.filterState.Groups)-1 { + b.WriteString("\n") + } + } + + // Apply panel styling + content := b.String() + if f.focused { + return f.styles.PanelActive. + Width(f.width). + Height(f.height). + Render(content) + } + return f.styles.PanelInactive. + Width(f.width). + Height(f.height). + Render(content) +} + +// SetSize sets the size of the filter panel +func (f *FilterPanel) SetSize(width, height int) { + f.width = width + f.height = height +} + +// SetFocused sets whether the panel is focused +func (f *FilterPanel) SetFocused(focused bool) { + f.focused = focused +} + +// FilterState returns the current filter state +func (f *FilterPanel) FilterState() *models.FilterState { + return f.filterState +} + +// SetFilterState updates the filter state +func (f *FilterPanel) SetFilterState(state *models.FilterState) { + f.filterState = state +} + +// FilterChangedMsg is sent when a filter selection changes +type FilterChangedMsg struct{} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/ui/components/help.go b/internal/ui/components/help.go new file mode 100644 index 0000000..d0696df --- /dev/null +++ b/internal/ui/components/help.go @@ -0,0 +1,173 @@ +package components + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/lipgloss" + "github.com/samuelenocsson/devops-tui/internal/ui/theme" +) + +// HelpPanel displays keyboard shortcuts +type HelpPanel struct { + keys theme.KeyMap + styles theme.Styles + visible bool + width int + height int +} + +// NewHelpPanel creates a new help panel +func NewHelpPanel(keys theme.KeyMap, styles theme.Styles) HelpPanel { + return HelpPanel{ + keys: keys, + styles: styles, + } +} + +// View renders the help panel +func (h HelpPanel) View() string { + if !h.visible { + return "" + } + + var b strings.Builder + + title := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#F9FAFB")). + MarginBottom(1). + Render("Keyboard Shortcuts") + + b.WriteString(title) + b.WriteString("\n\n") + + // Group shortcuts by category + sections := []struct { + title string + bindings []key.Binding + }{ + { + title: "Navigation", + bindings: []key.Binding{ + h.keys.Up, + h.keys.Down, + h.keys.Top, + h.keys.Bottom, + h.keys.NextPanel, + h.keys.PrevPanel, + }, + }, + { + title: "Actions", + bindings: []key.Binding{ + h.keys.Select, + h.keys.Open, + h.keys.View, + h.keys.Search, + h.keys.Refresh, + }, + }, + { + title: "General", + bindings: []key.Binding{ + h.keys.Help, + h.keys.Back, + h.keys.Quit, + }, + }, + } + + for i, section := range sections { + // Section title + sectionTitle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7C3AED")). + Render(section.title) + b.WriteString(sectionTitle) + b.WriteString("\n") + + // Key bindings + for _, binding := range section.bindings { + keyStyle := h.styles.HelpKey.Width(12) + descStyle := h.styles.HelpDesc + + help := binding.Help() + line := keyStyle.Render(help.Key) + descStyle.Render(help.Desc) + b.WriteString(line) + b.WriteString("\n") + } + + // Add spacing between sections + if i < len(sections)-1 { + b.WriteString("\n") + } + } + + // Footer + b.WriteString("\n") + footer := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6B7280")). + Italic(true). + Render("Press ? or Esc to close") + b.WriteString(footer) + + content := b.String() + + // Center the help panel + helpWidth := 40 + helpHeight := 25 + + panel := h.styles.HelpPanel. + Width(helpWidth). + Height(helpHeight). + Background(lipgloss.Color("#1F2937")). + Render(content) + + // Create overlay positioning + return lipgloss.Place( + h.width, + h.height, + lipgloss.Center, + lipgloss.Center, + panel, + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(lipgloss.Color("#000000")), + ) +} + +// SetVisible sets whether the help panel is visible +func (h *HelpPanel) SetVisible(visible bool) { + h.visible = visible +} + +// IsVisible returns whether the help panel is visible +func (h *HelpPanel) IsVisible() bool { + return h.visible +} + +// Toggle toggles the visibility of the help panel +func (h *HelpPanel) Toggle() { + h.visible = !h.visible +} + +// SetSize sets the size of the help panel +func (h *HelpPanel) SetSize(width, height int) { + h.width = width + h.height = height +} + +// ShortHelp returns a short help string for the status bar +func ShortHelp(keys theme.KeyMap, styles theme.Styles) string { + bindings := keys.ShortHelp() + var parts []string + + for _, b := range bindings { + help := b.Help() + key := styles.HelpKey.Render(help.Key) + desc := styles.HelpDesc.Render(help.Desc) + parts = append(parts, key+" "+desc) + } + + return strings.Join(parts, " ") +} diff --git a/internal/ui/components/statemodal.go b/internal/ui/components/statemodal.go new file mode 100644 index 0000000..5e42449 --- /dev/null +++ b/internal/ui/components/statemodal.go @@ -0,0 +1,223 @@ +package components + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/samuelenocsson/devops-tui/internal/models" + "github.com/samuelenocsson/devops-tui/internal/ui/theme" +) + +var defaultStates = []string{"New", "Active", "Resolved", "Closed"} + +// StateModal is a modal for changing work item state +type StateModal struct { + visible bool + item *models.WorkItem + states []string + statesByType map[string][]models.WorkItemStateInfo + cursor int + styles theme.Styles + keys theme.KeyMap + width int + height int +} + +// NewStateModal creates a new state modal +func NewStateModal(styles theme.Styles, keys theme.KeyMap) StateModal { + return StateModal{ + states: defaultStates, + styles: styles, + keys: keys, + } +} + +// SetStatesByType sets the available states per work item type +func (m *StateModal) SetStatesByType(statesByType map[string][]models.WorkItemStateInfo) { + m.statesByType = statesByType +} + +// Init initializes the modal +func (m StateModal) Init() tea.Cmd { + return nil +} + +// Update handles messages +func (m StateModal) Update(msg tea.Msg) (StateModal, tea.Cmd) { + if !m.visible { + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Up): + if m.cursor > 0 { + m.cursor-- + } + case key.Matches(msg, m.keys.Down): + if m.cursor < len(m.states)-1 { + m.cursor++ + } + case key.Matches(msg, m.keys.Select): + if m.item != nil { + newState := m.states[m.cursor] + return m, func() tea.Msg { + return StateChangeRequestMsg{ + Item: *m.item, + NewState: newState, + } + } + } + case key.Matches(msg, m.keys.Back): + m.visible = false + return m, func() tea.Msg { return ModalClosedMsg{} } + } + } + + return m, nil +} + +// View renders the modal +func (m StateModal) View() string { + if !m.visible { + return "" + } + + // Modal dimensions + modalWidth := 40 + modalHeight := len(m.states) + 6 + + // Build content + var b strings.Builder + + // Title + title := "Change State" + if m.item != nil { + title = lipgloss.NewStyle().Bold(true).Render("Change State") + itemInfo := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#9CA3AF")). + Render("#" + itoa(m.item.ID) + " " + truncateStr(m.item.Title, 25)) + b.WriteString(title + "\n") + b.WriteString(itemInfo + "\n\n") + } + + // Current state indicator + if m.item != nil { + currentStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#60A5FA")) + b.WriteString(currentStyle.Render("Current: "+string(m.item.State)) + "\n\n") + } + + // State options + for i, state := range m.states { + cursor := " " + if i == m.cursor { + cursor = "β–Έ " + } + + style := lipgloss.NewStyle() + if i == m.cursor { + style = style.Bold(true).Foreground(lipgloss.Color("#7C3AED")) + } + + // Highlight if this is the current state + if m.item != nil && state == string(m.item.State) { + style = style.Foreground(lipgloss.Color("#10B981")) + } + + b.WriteString(cursor + style.Render(state) + "\n") + } + + // Help text + b.WriteString("\n") + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")) + b.WriteString(helpStyle.Render("Enter: confirm Esc: cancel")) + + // Modal style + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7C3AED")). + Padding(1, 2). + Width(modalWidth). + Height(modalHeight). + Background(lipgloss.Color("#1F2937")) + + modal := modalStyle.Render(b.String()) + + // Center the modal + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal) +} + +// SetVisible sets the visibility +func (m *StateModal) SetVisible(visible bool) { + m.visible = visible + if visible { + m.cursor = 0 + // Try to set cursor to current state + if m.item != nil { + for i, state := range m.states { + if state == string(m.item.State) { + m.cursor = i + break + } + } + } + } +} + +// IsVisible returns whether the modal is visible +func (m *StateModal) IsVisible() bool { + return m.visible +} + +// SetItem sets the work item to modify +func (m *StateModal) SetItem(item *models.WorkItem) { + m.item = item + // Update states based on work item type + if item != nil && m.statesByType != nil { + if states, ok := m.statesByType[string(item.Type)]; ok && len(states) > 0 { + m.states = make([]string, len(states)) + for i, s := range states { + m.states[i] = s.Name + } + return + } + } + // Fallback to defaults + m.states = defaultStates +} + +// SetSize sets the modal container size +func (m *StateModal) SetSize(width, height int) { + m.width = width + m.height = height +} + +// StateChangeRequestMsg is sent when user confirms state change +type StateChangeRequestMsg struct { + Item models.WorkItem + NewState string +} + +// StateChangedMsg is sent when state change is complete +type StateChangedMsg struct { + Item models.WorkItem +} + +// ModalClosedMsg is sent when a modal is closed +type ModalClosedMsg struct{} + +// Helper function +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/ui/components/workitems.go b/internal/ui/components/workitems.go new file mode 100644 index 0000000..2fa1c57 --- /dev/null +++ b/internal/ui/components/workitems.go @@ -0,0 +1,367 @@ +package components + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/samuelenocsson/devops-tui/internal/models" + "github.com/samuelenocsson/devops-tui/internal/ui/theme" +) + +// Column definitions +type column struct { + title string + width int + minWidth int + flex bool // If true, this column takes remaining space +} + +// WorkItemsPanel is the work items list component +type WorkItemsPanel struct { + items []models.WorkItem + cursor int + styles theme.Styles + keys theme.KeyMap + width int + height int + focused bool + offset int // For scrolling + columns []column +} + +// NewWorkItemsPanel creates a new work items panel +func NewWorkItemsPanel(styles theme.Styles, keys theme.KeyMap) WorkItemsPanel { + return WorkItemsPanel{ + items: []models.WorkItem{}, + styles: styles, + keys: keys, + columns: []column{ + {title: "ID", width: 7, minWidth: 6}, + {title: "TYPE", width: 8, minWidth: 6}, + {title: "STATE", width: 12, minWidth: 8}, + {title: "ASSIGNED", width: 14, minWidth: 10}, + {title: "TITLE", flex: true, minWidth: 20}, + }, + } +} + +// Init initializes the work items panel +func (w WorkItemsPanel) Init() tea.Cmd { + return nil +} + +// Update handles messages for the work items panel +func (w WorkItemsPanel) Update(msg tea.Msg) (WorkItemsPanel, tea.Cmd) { + if !w.focused { + return w, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, w.keys.Up): + w.moveUp() + case key.Matches(msg, w.keys.Down): + w.moveDown() + case key.Matches(msg, w.keys.Top): + w.moveToTop() + case key.Matches(msg, w.keys.Bottom): + w.moveToBottom() + case key.Matches(msg, w.keys.Open): + if w.SelectedItem() != nil { + return w, func() tea.Msg { return OpenWorkItemMsg{Item: *w.SelectedItem()} } + } + case key.Matches(msg, w.keys.View): + if w.SelectedItem() != nil { + return w, func() tea.Msg { return ViewWorkItemMsg{Item: *w.SelectedItem()} } + } + } + } + + return w, nil +} + +// View renders the work items panel +func (w WorkItemsPanel) View() string { + var b strings.Builder + + // Calculate column widths + colWidths := w.calculateColumnWidths() + + // Header + header := w.renderHeader(colWidths) + b.WriteString(header) + b.WriteString("\n") + + // Separator line + separator := w.renderSeparator(colWidths) + b.WriteString(separator) + b.WriteString("\n") + + // Items + if len(w.items) == 0 { + emptyMsg := w.styles.Subtitle.Render(" No work items found") + b.WriteString(emptyMsg) + } else { + visibleItems := w.visibleItemCount() + + // Render visible items + for i := w.offset; i < len(w.items) && i < w.offset+visibleItems; i++ { + item := w.items[i] + isCursor := i == w.cursor + line := w.renderItem(item, isCursor, colWidths) + b.WriteString(line) + if i < len(w.items)-1 && i < w.offset+visibleItems-1 { + b.WriteString("\n") + } + } + } + + // Apply panel styling + content := b.String() + if w.focused { + return w.styles.PanelActive. + Width(w.width). + Height(w.height). + Render(content) + } + return w.styles.PanelInactive. + Width(w.width). + Height(w.height). + Render(content) +} + +func (w *WorkItemsPanel) calculateColumnWidths() []int { + availableWidth := w.width - 6 // Account for borders and padding + + // Calculate fixed columns total width + fixedWidth := 0 + flexCount := 0 + for _, col := range w.columns { + if col.flex { + flexCount++ + } else { + fixedWidth += col.width + 1 // +1 for separator + } + } + + // Calculate flex column width + flexWidth := availableWidth - fixedWidth + if flexWidth < 20 { + flexWidth = 20 + } + + // Build widths array + widths := make([]int, len(w.columns)) + for i, col := range w.columns { + if col.flex { + widths[i] = flexWidth / flexCount + } else { + widths[i] = col.width + } + } + + return widths +} + +func (w *WorkItemsPanel) renderHeader(colWidths []int) string { + headerStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#9CA3AF")) + + var parts []string + for i, col := range w.columns { + width := colWidths[i] + title := col.title + if len(title) > width { + title = title[:width] + } + parts = append(parts, headerStyle.Width(width).Render(title)) + } + + return " " + strings.Join(parts, " ") +} + +func (w *WorkItemsPanel) renderSeparator(colWidths []int) string { + sepStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#374151")) + + // Use content width (accounting for panel padding) + contentWidth := w.width - 4 + if contentWidth < 10 { + contentWidth = 10 + } + + return sepStyle.Render(strings.Repeat("─", contentWidth)) +} + +func (w *WorkItemsPanel) renderItem(item models.WorkItem, isCursor bool, colWidths []int) string { + // Cursor indicator + cursor := " " + if isCursor { + cursor = "β–Έ " + } + + // Format values + id := fmt.Sprintf("#%d", item.ID) + typeStr := item.ShortType() + stateStr := string(item.State) + assigned := truncateStr(item.AssignedTo, colWidths[3]) + if assigned == "" { + assigned = "-" + } + title := truncateStr(item.Title, colWidths[4]) + + // For cursor row, use plain text with unified background + if isCursor { + rowStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#F9FAFB")). + Background(lipgloss.Color("#7C3AED")). // Purple highlight + Width(w.width - 4) + + // Build plain text cells (no individual colors) + cells := []string{ + padRight(truncateStr(id, colWidths[0]), colWidths[0]), + padRight(truncateStr(typeStr, colWidths[1]), colWidths[1]), + padRight(truncateStr(stateStr, colWidths[2]), colWidths[2]), + padRight(assigned, colWidths[3]), + padRight(title, colWidths[4]), + } + row := cursor + strings.Join(cells, " ") + return rowStyle.Render(row) + } + + // Non-cursor rows with individual cell colors + idStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#60A5FA")) + typeStyle := w.styles.TypeBadge(string(item.Type)) + stateStyle := w.styles.StateBadge(string(item.State)) + assignedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#9CA3AF")) + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#F9FAFB")) + + // Build cells with proper width and colors + cells := []string{ + idStyle.Width(colWidths[0]).Render(truncateStr(id, colWidths[0])), + typeStyle.Width(colWidths[1]).Render(truncateStr(typeStr, colWidths[1])), + stateStyle.Width(colWidths[2]).Render(truncateStr(stateStr, colWidths[2])), + assignedStyle.Width(colWidths[3]).Render(assigned), + titleStyle.Width(colWidths[4]).Render(title), + } + + row := cursor + strings.Join(cells, " ") + return row +} + +func padRight(s string, width int) string { + if len(s) >= width { + return s + } + return s + strings.Repeat(" ", width-len(s)) +} + +func truncateStr(s string, maxLen int) string { + if maxLen <= 0 { + return "" + } + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." +} + +func (w *WorkItemsPanel) visibleItemCount() int { + visible := w.height - 5 // header, separator, borders + if visible < 1 { + visible = 1 + } + return visible +} + +func (w *WorkItemsPanel) moveUp() { + if w.cursor > 0 { + w.cursor-- + } +} + +func (w *WorkItemsPanel) moveDown() { + if w.cursor < len(w.items)-1 { + w.cursor++ + } +} + +func (w *WorkItemsPanel) moveToTop() { + w.cursor = 0 + w.offset = 0 +} + +func (w *WorkItemsPanel) moveToBottom() { + if len(w.items) > 0 { + w.cursor = len(w.items) - 1 + } +} + +// SetSize sets the size of the work items panel +func (w *WorkItemsPanel) SetSize(width, height int) { + w.width = width + w.height = height + + // Adjust offset to keep cursor visible (now that we have correct height) + visible := w.visibleItemCount() + if w.cursor < w.offset { + w.offset = w.cursor + } + if w.cursor >= w.offset+visible { + w.offset = w.cursor - visible + 1 + } + if w.offset < 0 { + w.offset = 0 + } +} + +// SetFocused sets whether the panel is focused +func (w *WorkItemsPanel) SetFocused(focused bool) { + w.focused = focused +} + +// SetItems sets the work items +func (w *WorkItemsPanel) SetItems(items []models.WorkItem) { + oldLen := len(w.items) + w.items = items + + // Only reset position if this is new data (not just a refresh) + if oldLen == 0 && len(items) > 0 { + w.cursor = 0 + w.offset = 0 + } + + // Clamp cursor to valid range + if w.cursor >= len(items) { + w.cursor = len(items) - 1 + } + if w.cursor < 0 { + w.cursor = 0 + } +} + +// SelectedItem returns the currently selected work item +func (w *WorkItemsPanel) SelectedItem() *models.WorkItem { + if w.cursor >= 0 && w.cursor < len(w.items) { + return &w.items[w.cursor] + } + return nil +} + +// OpenWorkItemMsg is sent when a work item should be opened in browser +type OpenWorkItemMsg struct { + Item models.WorkItem +} + +// ViewWorkItemMsg is sent when a work item should be viewed in detail +type ViewWorkItemMsg struct { + Item models.WorkItem +} diff --git a/internal/ui/theme/keys.go b/internal/ui/theme/keys.go new file mode 100644 index 0000000..809d19a --- /dev/null +++ b/internal/ui/theme/keys.go @@ -0,0 +1,129 @@ +package theme + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap defines all keyboard shortcuts +type KeyMap struct { + // Navigation + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Top key.Binding + Bottom key.Binding + NextPanel key.Binding + PrevPanel key.Binding + + // Actions + Select key.Binding + Open key.Binding + View key.Binding + Search key.Binding + Refresh key.Binding + Help key.Binding + Back key.Binding + Quit key.Binding + ChangeState key.Binding + CreateBranch key.Binding +} + +// DefaultKeyMap returns the default key bindings +func DefaultKeyMap() KeyMap { + return KeyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "left"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("β†’/l", "right"), + ), + Top: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("g", "top"), + ), + Bottom: key.NewBinding( + key.WithKeys("G"), + key.WithHelp("G", "bottom"), + ), + NextPanel: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("Tab", "next panel"), + ), + PrevPanel: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("Shift+Tab", "prev panel"), + ), + Select: key.NewBinding( + key.WithKeys("enter", " "), + key.WithHelp("Enter/Space", "select"), + ), + Open: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("Enter", "open in browser"), + ), + View: key.NewBinding( + key.WithKeys("v"), + key.WithHelp("v", "view details"), + ), + Search: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "search"), + ), + Refresh: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("Ctrl+r", "refresh"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("Esc", "back"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + ChangeState: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "change state"), + ), + CreateBranch: key.NewBinding( + key.WithKeys("b"), + key.WithHelp("b", "create branch"), + ), + } +} + +// ShortHelp returns a short help text for the status bar +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.NextPanel, + k.Up, k.Down, + k.Top, k.Bottom, + k.Open, + k.Help, + } +} + +// FullHelp returns all key bindings for the help panel +func (k KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Top, k.Bottom}, + {k.NextPanel, k.PrevPanel}, + {k.Select, k.Open, k.View}, + {k.ChangeState, k.CreateBranch}, + {k.Search, k.Refresh}, + {k.Help, k.Back, k.Quit}, + } +} diff --git a/internal/ui/theme/styles.go b/internal/ui/theme/styles.go new file mode 100644 index 0000000..4bbad96 --- /dev/null +++ b/internal/ui/theme/styles.go @@ -0,0 +1,234 @@ +package theme + +import "github.com/charmbracelet/lipgloss" + +// Colors +var ( + colorPrimary = lipgloss.Color("#7C3AED") // Purple + colorSecondary = lipgloss.Color("#06B6D4") // Cyan + colorSuccess = lipgloss.Color("#10B981") // Green + colorWarning = lipgloss.Color("#F59E0B") // Yellow + colorError = lipgloss.Color("#EF4444") // Red + colorMuted = lipgloss.Color("#6B7280") // Gray + colorBorder = lipgloss.Color("#374151") // Dark gray + colorHighlight = lipgloss.Color("#1F2937") // Very dark gray + colorText = lipgloss.Color("#F9FAFB") // White + colorTextMuted = lipgloss.Color("#9CA3AF") // Light gray +) + +// Work item type colors +var typeColors = map[string]lipgloss.Color{ + "User Story": lipgloss.Color("#3B82F6"), // Blue + "Story": lipgloss.Color("#3B82F6"), + "Task": lipgloss.Color("#F59E0B"), // Yellow + "Bug": lipgloss.Color("#EF4444"), // Red + "Feature": lipgloss.Color("#8B5CF6"), // Purple + "Epic": lipgloss.Color("#EC4899"), // Pink +} + +// Work item state colors +var stateColors = map[string]lipgloss.Color{ + // Common states + "New": lipgloss.Color("#6B7280"), // Gray + "Active": lipgloss.Color("#3B82F6"), // Blue + "Resolved": lipgloss.Color("#10B981"), // Green + "Closed": lipgloss.Color("#6B7280"), // Gray + // Agile states + "To Do": lipgloss.Color("#F97316"), // Orange + "In Progress": lipgloss.Color("#8B5CF6"), // Purple + "Done": lipgloss.Color("#10B981"), // Green + "Testing": lipgloss.Color("#FBBF24"), // Yellow + // Additional states + "Removed": lipgloss.Color("#6B7280"), // Gray + "Approved": lipgloss.Color("#10B981"), // Green +} + +// Styles defines all UI styles +type Styles struct { + // Base styles + App lipgloss.Style + Title lipgloss.Style + Subtitle lipgloss.Style + StatusBar lipgloss.Style + + // Panel styles + PanelActive lipgloss.Style + PanelInactive lipgloss.Style + PanelTitle lipgloss.Style + + // Filter styles + FilterGroup lipgloss.Style + FilterGroupTitle lipgloss.Style + FilterOption lipgloss.Style + FilterSelected lipgloss.Style + FilterCursor lipgloss.Style + + // Work items list styles + ListHeader lipgloss.Style + ListItem lipgloss.Style + ListItemSelected lipgloss.Style + ListItemCursor lipgloss.Style + + // Detail view styles + DetailTitle lipgloss.Style + DetailSection lipgloss.Style + DetailSectionTitle lipgloss.Style + DetailLabel lipgloss.Style + DetailValue lipgloss.Style + DetailDescription lipgloss.Style + DetailTag lipgloss.Style + + // Help styles + HelpKey lipgloss.Style + HelpDesc lipgloss.Style + HelpPanel lipgloss.Style + + // Type and state badges + TypeBadge func(string) lipgloss.Style + StateBadge func(string) lipgloss.Style +} + +// DefaultStyles returns the default styles +func DefaultStyles() Styles { + return Styles{ + // Base styles + App: lipgloss.NewStyle(). + Background(lipgloss.Color("#111827")), + + Title: lipgloss.NewStyle(). + Bold(true). + Foreground(colorText). + Padding(0, 1), + + Subtitle: lipgloss.NewStyle(). + Foreground(colorTextMuted), + + StatusBar: lipgloss.NewStyle(). + Foreground(colorTextMuted). + Background(lipgloss.Color("#1F2937")). + Padding(0, 1), + + // Panel styles + PanelActive: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorPrimary). + Padding(0, 1), + + PanelInactive: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBorder). + Padding(0, 1), + + PanelTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(colorText). + Background(colorHighlight). + Padding(0, 1), + + // Filter styles + FilterGroup: lipgloss.NewStyle(). + MarginBottom(1), + + FilterGroupTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(colorText). + MarginBottom(0), + + FilterOption: lipgloss.NewStyle(). + Foreground(colorTextMuted). + PaddingLeft(1), + + FilterSelected: lipgloss.NewStyle(). + Foreground(colorSuccess). + PaddingLeft(1), + + FilterCursor: lipgloss.NewStyle(). + Foreground(colorPrimary). + Bold(true), + + // Work items list styles + ListHeader: lipgloss.NewStyle(). + Bold(true). + Foreground(colorTextMuted). + BorderBottom(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(colorBorder), + + ListItem: lipgloss.NewStyle(). + Foreground(colorText), + + ListItemSelected: lipgloss.NewStyle(). + Foreground(colorText). + Background(colorHighlight), + + ListItemCursor: lipgloss.NewStyle(). + Foreground(colorPrimary). + Bold(true), + + // Detail view styles + DetailTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(colorText). + MarginBottom(1), + + DetailSection: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBorder). + Padding(0, 1). + MarginBottom(1), + + DetailSectionTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(colorTextMuted). + MarginBottom(0), + + DetailLabel: lipgloss.NewStyle(). + Foreground(colorTextMuted). + Width(12), + + DetailValue: lipgloss.NewStyle(). + Foreground(colorText), + + DetailDescription: lipgloss.NewStyle(). + Foreground(colorText), + + DetailTag: lipgloss.NewStyle(). + Foreground(colorSecondary). + Background(lipgloss.Color("#1E3A5F")). + Padding(0, 1). + MarginRight(1), + + // Help styles + HelpKey: lipgloss.NewStyle(). + Foreground(colorPrimary). + Bold(true), + + HelpDesc: lipgloss.NewStyle(). + Foreground(colorTextMuted), + + HelpPanel: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBorder). + Padding(1, 2), + + // Type and state badge generators + TypeBadge: func(t string) lipgloss.Style { + color := typeColors[t] + if color == "" { + color = colorMuted + } + return lipgloss.NewStyle(). + Foreground(color). + Bold(true) + }, + + StateBadge: func(s string) lipgloss.Style { + color := stateColors[s] + if color == "" { + color = colorMuted + } + return lipgloss.NewStyle(). + Foreground(color) + }, + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..38be475 --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/samuelenocsson/devops-tui/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/pkg/browser/open.go b/pkg/browser/open.go new file mode 100644 index 0000000..e4fcd25 --- /dev/null +++ b/pkg/browser/open.go @@ -0,0 +1,26 @@ +package browser + +import ( + "os/exec" + "runtime" +) + +// Open opens the specified URL in the default browser +func Open(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "rundll32" + args = []string{"url.dll,FileProtocolHandler", url} + case "darwin": + cmd = "open" + args = []string{url} + default: // linux and others + cmd = "xdg-open" + args = []string{url} + } + + return exec.Command(cmd, args...).Start() +} diff --git a/pkg/git/branch.go b/pkg/git/branch.go new file mode 100644 index 0000000..88fdd28 --- /dev/null +++ b/pkg/git/branch.go @@ -0,0 +1,76 @@ +package git + +import ( + "fmt" + "os/exec" + "strings" +) + +// IsGitRepo checks if the current directory is a git repository +func IsGitRepo() bool { + cmd := exec.Command("git", "rev-parse", "--git-dir") + err := cmd.Run() + return err == nil +} + +// GetCurrentBranch returns the current branch name +func GetCurrentBranch() (string, error) { + cmd := exec.Command("git", "branch", "--show-current") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("getting current branch: %w", err) + } + return strings.TrimSpace(string(output)), nil +} + +// BranchExists checks if a branch exists +func BranchExists(name string) bool { + cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/heads/"+name) + err := cmd.Run() + return err == nil +} + +// CreateBranch creates a new branch and optionally checks it out +func CreateBranch(name string, checkout bool) error { + if BranchExists(name) { + return fmt.Errorf("branch '%s' already exists", name) + } + + if checkout { + // Create and checkout + cmd := exec.Command("git", "checkout", "-b", name) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("creating branch: %s", strings.TrimSpace(string(output))) + } + } else { + // Just create + cmd := exec.Command("git", "branch", name) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("creating branch: %s", strings.TrimSpace(string(output))) + } + } + + return nil +} + +// CheckoutBranch checks out an existing branch +func CheckoutBranch(name string) error { + cmd := exec.Command("git", "checkout", name) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("checking out branch: %s", strings.TrimSpace(string(output))) + } + return nil +} + +// HasUncommittedChanges checks if there are uncommitted changes +func HasUncommittedChanges() bool { + cmd := exec.Command("git", "status", "--porcelain") + output, err := cmd.Output() + if err != nil { + return false + } + return len(strings.TrimSpace(string(output))) > 0 +}