diff --git a/README.md b/README.md index 0963d8f..4b5117d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 ``` @@ -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 ``` @@ -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 diff --git a/TESTING.md b/TESTING.md index 4b330c2..0058d9f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 ``` diff --git a/app/.env.template b/app/.env.template index 761030f..169f258 100644 --- a/app/.env.template +++ b/app/.env.template @@ -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 diff --git a/app/check_categories.go b/app/check_categories.go index 8660d3b..7f3f1e7 100644 --- a/app/check_categories.go +++ b/app/check_categories.go @@ -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) } diff --git a/app/config.go b/app/config.go index 3c94a29..e608f40 100644 --- a/app/config.go +++ b/app/config.go @@ -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" } @@ -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 diff --git a/app/config_test.go b/app/config_test.go index 9002869..1873469 100644 --- a/app/config_test.go +++ b/app/config_test.go @@ -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", @@ -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) - } }) } } @@ -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 @@ -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{ @@ -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 @@ -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" diff --git a/app/model.go b/app/model.go index f9f1529..4304bfe 100644 --- a/app/model.go +++ b/app/model.go @@ -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() != "" { diff --git a/app/model_test.go b/app/model_test.go index cc4bced..4c82724 100644 --- a/app/model_test.go +++ b/app/model_test.go @@ -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", diff --git a/app/view_config_wizard.go b/app/view_config_wizard.go index 747e689..450ce18 100644 --- a/app/view_config_wizard.go +++ b/app/view_config_wizard.go @@ -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") diff --git a/app/view_list.go b/app/view_list.go index ea696e4..b901a69 100644 --- a/app/view_list.go +++ b/app/view_list.go @@ -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 diff --git a/app/views.go b/app/views.go index f657e74..2795964 100644 --- a/app/views.go +++ b/app/views.go @@ -280,9 +280,9 @@ func buildSourceInfo(source *ConfigSource) string { case "file": if source.ConfigPath != "" { displayPath := abbreviateHomePath(source.ConfigPath) - sourceDesc = fmt.Sprintf("Source:📄%s", displayPath) + sourceDesc = fmt.Sprintf("Source:%s", displayPath) } else { - sourceDesc = "Source:📄file" + sourceDesc = "Source:file" } } } @@ -298,9 +298,9 @@ func buildSourceInfo(source *ConfigSource) string { if source.OrganizationURL == "file" || source.Project == "file" || source.Team == "file" { if source.ConfigPath != "" { displayPath := abbreviateHomePath(source.ConfigPath) - srcParts = append(srcParts, fmt.Sprintf("📄%s", displayPath)) + srcParts = append(srcParts, displayPath) } else { - srcParts = append(srcParts, "📄file") + srcParts = append(srcParts, "file") } } if len(srcParts) > 0 {