Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 16 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,8 @@ A terminal user interface (TUI) for Azure DevOps task management, built with Go
- In-app update check
- Use go-selfupdate if update available (and offer "skip version")

- config file for what is now the .env variables (env variables for config locaaaaatn)
- changelog generation
- changelog support in-app
- add license file (MIT)
- stylize the header bar
- justfile

## Features
Expand All @@ -37,8 +34,8 @@ A terminal user interface (TUI) for Azure DevOps task management, built with Go
## Prerequisites

- Go 1.21 or higher
- Azure CLI installed and configured
- Azure DevOps account
- Azure CLI
- Azure DevOps Board

## Setup

Expand Down Expand Up @@ -240,23 +237,23 @@ Environment variables are fully supported and useful for:

Supported variables:
```bash
export AZURE_DEVOPS_ORG_URL="https://dev.azure.com/your-org"
export AZURE_DEVOPS_PROJECT="your-project"
export AZURE_DEVOPS_TEAM="your-team" # optional
export HIPPO_ADO_ORG_URL="https://dev.azure.com/your-org"
export HIPPO_ADO_PROJECT="your-project"
export HIPPO_ADO_TEAM="your-team"
```

Example: Override project in CI/CD:
```bash
# Config file has your default project
# Override just the project for this run
export AZURE_DEVOPS_PROJECT="CI-Test-Project"
export HIPPO_ADO_PROJECT="CI-Test-Project"
./hippo
```

Example: Use `.env` file for development:
```bash
# Create .env file
echo "AZURE_DEVOPS_PROJECT=DevProject" > .env
echo "HIPPO_ADO_PROJECT=DevProject" > .env

# godotenv automatically loads .env
./hippo
Expand All @@ -282,7 +279,7 @@ echo "AZURE_DEVOPS_PROJECT=DevProject" > .env
./hippo --project "OtherProject"

# Or use environment variables
export AZURE_DEVOPS_PROJECT="OtherProject"
export HIPPO_ADO_PROJECT="OtherProject"
./hippo

# Reconfigure to different project permanently
Expand All @@ -295,8 +292,8 @@ export AZURE_DEVOPS_PROJECT="OtherProject"
steps:
- name: Run Hippo
env:
AZURE_DEVOPS_ORG_URL: https://dev.azure.com/my-org
AZURE_DEVOPS_PROJECT: CI-Project
HIPPO_ADO_ORG_URL: https://dev.azure.com/my-org
HIPPO_ADO_PROJECT: CI-Project
run: ./hippo
```

Expand All @@ -307,8 +304,9 @@ docker run -v ~/.config/hippo:/root/.config/hippo hippo

# Option 2: Use environment variables (must provide ALL required fields)
docker run \
-e AZURE_DEVOPS_ORG_URL="https://dev.azure.com/my-org" \
-e AZURE_DEVOPS_PROJECT="MyProject" \
-e HIPPO_ADO_ORG_URL="https://dev.azure.com/my-org" \
-e HIPPO_ADO_PROJECT="MyProject" \
-e HIPPO_ADO_TEAM="MyTeam" \
hippo
```

Expand All @@ -324,9 +322,9 @@ If you previously used `.env` files for configuration:
```

2. Enter the same values you had in your `.env` file:
- `AZURE_DEVOPS_ORG_URL` → `organization_url`
- `AZURE_DEVOPS_PROJECT` → `project`
- `AZURE_DEVOPS_TEAM` → `team`
- `HIPPO_ADO_ORG_URL` → `organization_url`
- `HIPPO_ADO_PROJECT` → `project`
- `HIPPO_ADO_TEAM` → `team`

3. (Optional) Remove old `.env` file:
```bash
Expand Down
6 changes: 3 additions & 3 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ If tests fail because environment variables are set in your shell, you can eithe

1. Unset them temporarily:
```bash
unset AZURE_DEVOPS_ORG_URL
unset AZURE_DEVOPS_PROJECT
unset AZURE_DEVOPS_TEAM
unset HIPPO_ADO_ORG_URL
unset HIPPO_ADO_PROJECT
unset HIPPO_ADO_TEAM
go test -v
```

Expand Down
14 changes: 11 additions & 3 deletions app/.env.template
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization
AZURE_DEVOPS_PROJECT=your-project-name
AZURE_DEVOPS_TEAM=your-team-name
# Hippo Configuration - Environment Variables Template
# Copy this file to .env and fill in your values

# Azure DevOps Organization URL (required)
HIPPO_ADO_ORG_URL=https://dev.azure.com/your-org

# Azure DevOps Project (required)
HIPPO_ADO_PROJECT=your-project

# Azure DevOps Team (required)
HIPPO_ADO_TEAM=your-team
6 changes: 3 additions & 3 deletions app/check_categories.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ func getAzureCliToken() (string, error) {
func main() {
_ = godotenv.Load()

organizationURL := os.Getenv("AZURE_DEVOPS_ORG_URL")
project := os.Getenv("AZURE_DEVOPS_PROJECT")
organizationURL := os.Getenv("HIPPO_ADO_ORG_URL")
project := os.Getenv("HIPPO_ADO_PROJECT")

if organizationURL == "" || project == "" {
fmt.Println("Error: AZURE_DEVOPS_ORG_URL and AZURE_DEVOPS_PROJECT must be set in .env")
fmt.Println("Error: HIPPO_ADO_ORG_URL and HIPPO_ADO_PROJECT must be set in .env")
os.Exit(1)
}

Expand Down
10 changes: 4 additions & 6 deletions app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,15 @@ func LoadConfig(flags *FlagConfig) (*Config, *ConfigSource, error) {
}

// 2. Merge with environment variables (non-empty overrides)
if orgURL := os.Getenv("AZURE_DEVOPS_ORG_URL"); orgURL != "" {
if orgURL := os.Getenv("HIPPO_ADO_ORG_URL"); orgURL != "" {
config.OrganizationURL = orgURL
source.OrganizationURL = "env"
}
if project := os.Getenv("AZURE_DEVOPS_PROJECT"); project != "" {
if project := os.Getenv("HIPPO_ADO_PROJECT"); project != "" {
config.Project = project
source.Project = "env"
}
if team := os.Getenv("AZURE_DEVOPS_TEAM"); team != "" {
if team := os.Getenv("HIPPO_ADO_TEAM"); team != "" {
config.Team = team
source.Team = "env"
}
Expand Down Expand Up @@ -206,10 +206,8 @@ func ValidateConfig(config *Config) error {
if config.Project == "" {
return fmt.Errorf("project is required")
}

// Default team to project name if not set
if config.Team == "" {
config.Team = config.Project
return fmt.Errorf("team is required")
}

return nil
Expand Down
30 changes: 11 additions & 19 deletions app/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ func TestValidateConfig(t *testing.T) {
wantErr: false,
},
{
name: "valid config with team defaulting to project",
name: "missing team",
config: &Config{
ConfigVersion: 1,
OrganizationURL: "https://dev.azure.com/org",
Project: "project",
Team: "",
},
wantErr: false,
wantErr: true,
},
{
name: "missing organization URL",
Expand All @@ -52,18 +52,10 @@ func TestValidateConfig(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Store original team value
originalTeam := tt.config.Team

err := ValidateConfig(tt.config)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateConfig() error = %v, wantErr %v", err, tt.wantErr)
}

// Check that team defaults to project when empty
if err == nil && originalTeam == "" && tt.config.Team != tt.config.Project {
t.Errorf("ValidateConfig() should set team to project name when empty, got %v, want %v", tt.config.Team, tt.config.Project)
}
})
}
}
Expand Down Expand Up @@ -148,8 +140,8 @@ team: "file-team"

// Test 2: Environment variable overrides config file
t.Run("env var overrides config file", func(t *testing.T) {
os.Setenv("AZURE_DEVOPS_PROJECT", "env-project")
defer os.Unsetenv("AZURE_DEVOPS_PROJECT")
os.Setenv("HIPPO_ADO_PROJECT", "env-project")
defer os.Unsetenv("HIPPO_ADO_PROJECT")

flags := &FlagConfig{}
flags.ConfigPath = &configPath
Expand Down Expand Up @@ -177,8 +169,8 @@ team: "file-team"

// Test 3: Flag overrides everything
t.Run("flag overrides everything", func(t *testing.T) {
os.Setenv("AZURE_DEVOPS_PROJECT", "env-project")
defer os.Unsetenv("AZURE_DEVOPS_PROJECT")
os.Setenv("HIPPO_ADO_PROJECT", "env-project")
defer os.Unsetenv("HIPPO_ADO_PROJECT")

flagProject := "flag-project"
flags := &FlagConfig{
Expand All @@ -203,8 +195,8 @@ team: "file-team"

// Test 4: Empty env var is ignored
t.Run("empty env var is ignored", func(t *testing.T) {
os.Setenv("AZURE_DEVOPS_PROJECT", "")
defer os.Unsetenv("AZURE_DEVOPS_PROJECT")
os.Setenv("HIPPO_ADO_PROJECT", "")
defer os.Unsetenv("HIPPO_ADO_PROJECT")

flags := &FlagConfig{}
flags.ConfigPath = &configPath
Expand Down Expand Up @@ -279,9 +271,9 @@ team: "test-team"

func TestLoadConfig_NotFound(t *testing.T) {
// Clear any environment variables that might interfere
os.Unsetenv("AZURE_DEVOPS_ORG_URL")
os.Unsetenv("AZURE_DEVOPS_PROJECT")
os.Unsetenv("AZURE_DEVOPS_TEAM")
os.Unsetenv("HIPPO_ADO_ORG_URL")
os.Unsetenv("HIPPO_ADO_PROJECT")
os.Unsetenv("HIPPO_ADO_TEAM")

flags := &FlagConfig{}
nonExistentPath := "/nonexistent/config.yaml"
Expand Down
19 changes: 17 additions & 2 deletions app/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,24 @@ func (m model) getContentHeight() int {
// - Mode selector: 2 lines (modes + blank line)
// - Tab selector: 2 lines (tabs + blank line)
// - Hint (if present): 2 lines (hint + blank line)
// - Footer: 4 lines (blank + action log + separator + keybindings)
// - Footer:
// * 1 blank line
// * 0-1 action log line (if present)
// * 1 separator line
// * 1 keybindings line
// * 2 config bar lines (blank + bar, if config exists)

fixedHeight := 3 + 2 + 2 + 4 // = 11 lines minimum
fixedHeight := 3 + 2 + 2 // = 7 lines for title, mode, tabs

// Footer lines
footerLines := 3 // blank + separator + keybindings (minimum)
if m.lastActionLog != "" {
footerLines++ // action log line
}
if m.config != nil && m.configSource != nil {
footerLines += 2 // blank line + config bar
}
fixedHeight += footerLines

// Add hint lines if present
if m.getTabHint() != "" {
Expand Down
10 changes: 5 additions & 5 deletions app/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -649,12 +649,12 @@ func TestAdjustScrollOffset(t *testing.T) {
m.currentTab = currentSprint
m.sprints = make(map[sprintTab]*Sprint)
},
// With terminal height 30, fixed height = 11, content height = 19
// With terminal height 30, fixed height = 10, content height = 20
// Cursor at 30, scroll at 0 means cursor is at position 30
// Condition: 30 >= 0 + 19 (TRUE, cursor is below visible area)
// New scroll = 30 - 19 + 1 = 12
expectedScrollMin: 12,
expectedScrollMax: 12,
// Condition: 30 >= 0 + 20 (TRUE, cursor is below visible area)
// New scroll = 30 - 20 + 1 = 11
expectedScrollMin: 11,
expectedScrollMax: 11,
},
{
name: "Cursor in visible area - no scroll",
Expand Down
4 changes: 2 additions & 2 deletions app/view_config_wizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,11 @@ func (m model) renderConfigWizardView() string {

// Team field
content.WriteString(m.styles.EditSection.Render(
m.styles.EditLabel.Render("Team (optional):") + "\n" +
m.styles.EditLabel.Render("Team:") + "\n" +
" " + m.wizard.teamInput.View()))
content.WriteString("\n")
if m.wizard.fieldCursor == 2 {
content.WriteString(m.styles.EditHelp.Render(" Defaults to project name if not specified") + "\n")
content.WriteString(m.styles.EditHelp.Render(" Your Azure DevOps team name") + "\n")
}
content.WriteString("\n")

Expand Down
68 changes: 34 additions & 34 deletions app/view_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,45 +102,45 @@ func (m model) renderListView() string {
treeItems := m.getVisibleTreeItems()
if len(treeItems) == 0 {
content.WriteString(" No tasks found.\n")
}

// Calculate visible range based on scroll offset
contentHeight := m.getContentHeight()
startIdx := m.ui.scrollOffset
endIdx := m.ui.scrollOffset + contentHeight

// Total items including potential "Load More" item
totalItems := len(treeItems)
if m.hasMoreItems() {
totalItems++
}
} else {
// Calculate visible range based on scroll offset
contentHeight := m.getContentHeight()
startIdx := m.ui.scrollOffset
endIdx := m.ui.scrollOffset + contentHeight

// Total items including potential "Load More" item
totalItems := len(treeItems)
if m.hasMoreItems() {
totalItems++
}

// Clamp end index
if endIdx > totalItems {
endIdx = totalItems
}
// Clamp end index
if endIdx > totalItems {
endIdx = totalItems
}

// Render only visible items
for i := startIdx; i < endIdx; i++ {
// Check if this is the "Load More" item
if i >= len(treeItems) {
// This is the "Load More" item
if m.hasMoreItems() {
remaining := m.getRemainingCount()
loadMoreIdx := len(treeItems)
isSelected := m.ui.cursor == loadMoreIdx
loadMoreText := m.renderLoadMoreItem(isSelected, remaining, m.loadingMore)
content.WriteString(loadMoreText + "\n")
// Render only visible items
for i := startIdx; i < endIdx; i++ {
// Check if this is the "Load More" item
if i >= len(treeItems) {
// This is the "Load More" item
if m.hasMoreItems() {
remaining := m.getRemainingCount()
loadMoreIdx := len(treeItems)
isSelected := m.ui.cursor == loadMoreIdx
loadMoreText := m.renderLoadMoreItem(isSelected, remaining, m.loadingMore)
content.WriteString(loadMoreText + "\n")
}
continue
}
continue
}

treeItem := treeItems[i]
isSelected := m.ui.cursor == i
isBatchSelected := m.batch.selectedItems[treeItem.WorkItem.ID]
treeItem := treeItems[i]
isSelected := m.ui.cursor == i
isBatchSelected := m.batch.selectedItems[treeItem.WorkItem.ID]

line := m.renderTreeItemList(treeItem, isSelected, isBatchSelected)
content.WriteString(line + "\n")
line := m.renderTreeItemList(treeItem, isSelected, isBatchSelected)
content.WriteString(line + "\n")
}
}

// Footer with keybindings
Expand Down
Loading