From 3c687f2ed96749add589ebf10039eb3d71e72aed Mon Sep 17 00:00:00 2001 From: George Karr Date: Thu, 25 Sep 2025 14:05:39 -0500 Subject: [PATCH] Adding direction to add views and workflows (#33177) This looks like a log but it's just moving the code that lived in cmd/gm/ui.go into it's own pkg that makes it clearer where each part of the UI lives. I am also adding the functionality for bulk sprint demo to grab names from github usernames. --- tools/github-manage/cmd/gm/issue.go | 12 +- tools/github-manage/cmd/gm/main.go | 16 - tools/github-manage/cmd/gm/project.go | 29 +- tools/github-manage/cmd/gm/ui.go | 1694 ------------------- tools/github-manage/pkg/ghapi/models.go | 15 + tools/github-manage/pkg/ghapi/user.go | 47 + tools/github-manage/pkg/ghapi/workflows.go | 8 + tools/github-manage/pkg/tui/filter.go | 105 ++ tools/github-manage/pkg/tui/hotkeys.go | 306 ++++ tools/github-manage/pkg/tui/state_change.go | 200 +++ tools/github-manage/pkg/tui/table.go | 78 + tools/github-manage/pkg/tui/ui.go | 759 +++++++++ tools/github-manage/pkg/tui/view.go | 393 +++++ 13 files changed, 1918 insertions(+), 1744 deletions(-) delete mode 100644 tools/github-manage/cmd/gm/ui.go create mode 100644 tools/github-manage/pkg/ghapi/user.go create mode 100644 tools/github-manage/pkg/tui/filter.go create mode 100644 tools/github-manage/pkg/tui/hotkeys.go create mode 100644 tools/github-manage/pkg/tui/state_change.go create mode 100644 tools/github-manage/pkg/tui/table.go create mode 100644 tools/github-manage/pkg/tui/ui.go create mode 100644 tools/github-manage/pkg/tui/view.go diff --git a/tools/github-manage/cmd/gm/issue.go b/tools/github-manage/cmd/gm/issue.go index e38f575d7f..5801ec6fad 100644 --- a/tools/github-manage/cmd/gm/issue.go +++ b/tools/github-manage/cmd/gm/issue.go @@ -3,7 +3,8 @@ package main import ( "fmt" - tea "github.com/charmbracelet/bubbletea" + "fleetdm/gm/pkg/tui" + "github.com/spf13/cobra" ) @@ -16,14 +17,7 @@ var issuesCmd = &cobra.Command{ fmt.Printf("Error getting search flag: %v\n", err) return } - model := initializeModelForIssues(search) - p := tea.NewProgram(&model) - if _, err := p.Run(); err != nil { - fmt.Printf("Error running Bubble Tea program: %v\n", err) - } - if model.exitMessage != "" { - fmt.Println(model.exitMessage) - } + tui.RunTUI(tui.IssuesCommand, 0, 0, search) }, } diff --git a/tools/github-manage/cmd/gm/main.go b/tools/github-manage/cmd/gm/main.go index 595e422fe9..e40342c7eb 100644 --- a/tools/github-manage/cmd/gm/main.go +++ b/tools/github-manage/cmd/gm/main.go @@ -1,11 +1,8 @@ package main import ( - "bufio" "fmt" "log" - "os" - "strings" "fleetdm/gm/pkg/ghapi" "fleetdm/gm/pkg/logger" @@ -14,19 +11,6 @@ import ( "github.com/spf13/pflag" ) -// promptToContinue asks the user if they want to continue or quit -func promptToContinue() bool { - fmt.Printf("\nPress Enter to continue, or type 'q' to quit: ") - scanner := bufio.NewScanner(os.Stdin) - scanner.Scan() - input := strings.TrimSpace(scanner.Text()) - if strings.ToLower(input) == "q" || strings.ToLower(input) == "quit" { - fmt.Println("Exiting test...") - return false - } - return true -} - func main() { // Initialize logger if err := logger.Init(); err != nil { diff --git a/tools/github-manage/cmd/gm/project.go b/tools/github-manage/cmd/gm/project.go index 34dfcab43f..03d411f511 100644 --- a/tools/github-manage/cmd/gm/project.go +++ b/tools/github-manage/cmd/gm/project.go @@ -4,8 +4,8 @@ import ( "fmt" "fleetdm/gm/pkg/ghapi" + "fleetdm/gm/pkg/tui" - tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" ) @@ -26,14 +26,7 @@ var projectCmd = &cobra.Command{ return } - model := initializeModelForProject(projectID, limit) - p := tea.NewProgram(&model) - if _, err := p.Run(); err != nil { - fmt.Printf("Error running Bubble Tea program: %v\n", err) - } - if model.exitMessage != "" { - fmt.Println(model.exitMessage) - } + tui.RunTUI(tui.ProjectCommand, projectID, limit, "") }, } @@ -60,14 +53,7 @@ var estimatedCmd = &cobra.Command{ return } - model := initializeModelForEstimated(projectID, limit) - p := tea.NewProgram(&model) - if _, err := p.Run(); err != nil { - fmt.Printf("Error running Bubble Tea program: %v\n", err) - } - if model.exitMessage != "" { - fmt.Println(model.exitMessage) - } + tui.RunTUI(tui.EstimatedCommand, projectID, limit, "") }, } @@ -91,13 +77,6 @@ var sprintCmd = &cobra.Command{ return } - model := initializeModelForSprint(projectID, limit) - p := tea.NewProgram(&model) - if _, err := p.Run(); err != nil { - fmt.Printf("Error running Bubble Tea program: %v\n", err) - } - if model.exitMessage != "" { - fmt.Println(model.exitMessage) - } + tui.RunTUI(tui.SprintCommand, projectID, limit, "") }, } diff --git a/tools/github-manage/cmd/gm/ui.go b/tools/github-manage/cmd/gm/ui.go deleted file mode 100644 index c1fbbe2f0d..0000000000 --- a/tools/github-manage/cmd/gm/ui.go +++ /dev/null @@ -1,1694 +0,0 @@ -package main - -import ( - "fmt" - "sort" - "strings" - "time" - - "fleetdm/gm/pkg/ghapi" - "fleetdm/gm/pkg/logger" - - "github.com/charmbracelet/bubbles/progress" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/lipgloss" -) - -type CommandType int - -const ( - IssuesCommand CommandType = iota - ProjectCommand - EstimatedCommand - SprintCommand -) - -type WorkflowState int - -const ( - Loading WorkflowState = iota - NormalMode - IssueDetail - FilterInput - WorkflowSelection - LabelInput - ProjectInput - WorkflowRunning - WorkflowComplete -) - -type WorkflowType int - -const ( - BulkAddLabel WorkflowType = iota - BulkRemoveLabel - BulkSprintKickoff - BulkMilestoneClose - BulkKickOutOfSprint - BulkDemoSummary -) - -var WorkflowTypeValues = []string{ - "Bulk Add Label", - "Bulk Remove Label", - "Bulk Sprint Kickoff", - "Bulk Milestone Close", - "Bulk Kick Out Of Sprint", - "Bulk Demo Summary", -} - -type TaskStatus int - -const ( - TaskPending TaskStatus = iota - TaskInProgress - TaskSuccess - TaskError -) - -type WorkflowTask struct { - ID int - Description string - Status TaskStatus - Progress float64 - Error error -} - -var ( - statusStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Background(lipgloss.Color("#7D56F4")). - Padding(0, 1) - - successStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Background(lipgloss.Color("#04B575")). - Padding(0, 1) - - errorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Background(lipgloss.Color("#FF0000")). - Padding(0, 1) - - pendingStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Background(lipgloss.Color("#888888")). - Padding(0, 1) -) - -type processTaskMsg struct { - taskID int -} - -type actionStatusMsg struct { - index int - state string -} -type startAsyncWorkflowMsg struct { - actions []ghapi.Action -} - -type asyncStatusMsg struct { - status ghapi.Status -} - -type model struct { - choices []ghapi.Issue - cursor int - selected map[int]struct{} - relatedCache map[int][]int // issue number -> related issue numbers - spinner spinner.Model - totalCount int - totalAvailable int // reported total items in project (may exceed totalCount if limited) - rawFetchedCount int // number of items actually fetched before mode-specific filtering - selectedCount int - // Scrolling support - viewOffset int // Offset for scrolling view - viewHeight int // Number of visible lines for issues - // Filtering support - filterInput string - filteredChoices []ghapi.Issue - originalIndices []int // Maps filtered index to original index - // Issue detail view - detailViewport viewport.Model - glamourRenderer *glamour.TermRenderer - issueContent string - // Command-specific parameters - commandType CommandType - projectID int - limit int - search string - // Workflow state - workflowState WorkflowState - workflowType WorkflowType - workflowCursor int - labelInput string - projectInput string - errorMessage string - // Progress tracking - tasks []WorkflowTask - currentTask int - overallProgress progress.Model - githubOpInProgress bool // Ensure only one GitHub operation at a time - currentActions []ghapi.Action // Store current actions being processed - statusChan chan ghapi.Status // Channel for receiving status updates from AsyncManager - exitMessage string -} - -type issuesLoadedMsg struct { - issues []ghapi.Issue - totalAvailable int - rawFetched int -} - -type workflowResultMsg struct { - message string - err error -} - -type taskUpdateMsg struct { - taskID int - progress float64 - status TaskStatus - err error -} - -type workflowCompleteMsg struct { - success bool - message string -} - -type workflowExitMsg struct { - success bool - message string -} - -func initializeModel() model { - s := spinner.New() - s.Spinner = spinner.Moon - s.Style = s.Style.Foreground(spinner.New().Style.GetForeground()) - - p := progress.New(progress.WithDefaultGradient()) - p.Width = 40 - - // Initialize glamour renderer - renderer, _ := glamour.NewTermRenderer( - glamour.WithAutoStyle(), - glamour.WithWordWrap(80), - ) - - // Initialize viewport for issue details - vp := viewport.New(80, 20) - vp.Style = lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("62")). - Padding(1) - - return model{ - choices: nil, - cursor: 0, - selected: make(map[int]struct{}), - relatedCache: make(map[int][]int), - spinner: s, - totalCount: 0, - totalAvailable: 0, - rawFetchedCount: 0, - selectedCount: 0, - viewOffset: 0, - viewHeight: 15, // Default height, will be adjusted based on screen size - filterInput: "", - filteredChoices: nil, - originalIndices: nil, - detailViewport: vp, - glamourRenderer: renderer, - issueContent: "", - commandType: IssuesCommand, - workflowState: Loading, - workflowCursor: 0, - labelInput: "", - projectInput: "", - errorMessage: "", - tasks: []WorkflowTask{}, - currentTask: -1, - overallProgress: p, - githubOpInProgress: false, - currentActions: []ghapi.Action{}, - } -} - -func initializeModelForIssues(search string) model { - m := initializeModel() - m.search = search - return m -} - -func initializeModelForProject(projectID, limit int) model { - m := initializeModel() - m.commandType = ProjectCommand - m.projectID = projectID - m.limit = limit - return m -} - -func initializeModelForEstimated(projectID, limit int) model { - m := initializeModel() - m.commandType = EstimatedCommand - m.projectID = projectID - m.limit = limit - return m -} - -func initializeModelForSprint(projectID, limit int) model { - m := initializeModel() - m.commandType = SprintCommand - m.projectID = projectID - m.limit = limit - return m -} - -func (m *model) generateIssueContent(issue ghapi.Issue) string { - var content strings.Builder - - // Title - content.WriteString(fmt.Sprintf("# %s\n\n", issue.Title)) - - // Metadata section - content.WriteString("## Issue Details\n\n") - content.WriteString(fmt.Sprintf("**Number:** #%d\n\n", issue.Number)) - content.WriteString(fmt.Sprintf("**Type:** %s\n\n", issue.Typename)) - - // Estimate - if issue.Estimate > 0 { - content.WriteString(fmt.Sprintf("**Estimate:** %d\n\n", issue.Estimate)) - } else { - content.WriteString("**Estimate:** Not set\n\n") - } - - // Labels - if len(issue.Labels) > 0 { - content.WriteString("**Labels:** ") - var labelNames []string - for _, label := range issue.Labels { - labelNames = append(labelNames, label.Name) - } - content.WriteString(strings.Join(labelNames, ", ")) - content.WriteString("\n\n") - } else { - content.WriteString("**Labels:** None\n\n") - } - - // State and metadata - content.WriteString(fmt.Sprintf("**State:** %s\n\n", issue.State)) - content.WriteString(fmt.Sprintf("**Created:** %s\n\n", issue.CreatedAt)) - content.WriteString(fmt.Sprintf("**Updated:** %s\n\n", issue.UpdatedAt)) - - // Author - content.WriteString(fmt.Sprintf("**Author:** %s\n\n", issue.Author.Login)) - - // Assignees - if len(issue.Assignees) > 0 { - content.WriteString("**Assignees:** ") - var assigneeNames []string - for _, assignee := range issue.Assignees { - assigneeNames = append(assigneeNames, assignee.Login) - } - content.WriteString(strings.Join(assigneeNames, ", ")) - content.WriteString("\n\n") - } - - // Milestone - if issue.Milestone != nil { - content.WriteString(fmt.Sprintf("**Milestone:** %s\n\n", issue.Milestone.Title)) - } - - // Description - content.WriteString("## Description\n\n") - if issue.Body != "" { - content.WriteString(issue.Body) - } else { - content.WriteString("*No description provided.*") - } - content.WriteString("\n\n") - - return content.String() -} - -func (m *model) applyFilter() { - if m.filterInput == "" { - // No filter, show all issues - // Ensure base list is sorted - ghapi.SortIssuesForDisplay(m.choices) - m.filteredChoices = m.choices - m.originalIndices = make([]int, len(m.choices)) - for i := range m.originalIndices { - m.originalIndices[i] = i - } - return - } - - filter := strings.ToLower(m.filterInput) - m.filteredChoices = nil - m.originalIndices = nil - - // Ensure base list is sorted before applying filter - ghapi.SortIssuesForDisplay(m.choices) - for i, issue := range m.choices { - if m.matchesFilter(issue, filter) { - m.filteredChoices = append(m.filteredChoices, issue) - m.originalIndices = append(m.originalIndices, i) - } - } - - // Reset cursor if it's beyond the filtered results - if m.cursor >= len(m.filteredChoices) { - m.cursor = 0 - } -} - -// --- Sorting helpers for Normal view ordering --- - -// labelNamesLower returns a set of lowercase label names for fast lookup. -// Sorting moved to ghapi.SortIssuesForDisplay - -func (m *model) matchesFilter(issue ghapi.Issue, filter string) bool { - // Check issue number - if strings.Contains(strings.ToLower(fmt.Sprintf("#%d", issue.Number)), filter) { - return true - } - if strings.Contains(strings.ToLower(fmt.Sprintf("%d", issue.Number)), filter) { - return true - } - - // Check title - if strings.Contains(strings.ToLower(issue.Title), filter) { - return true - } - - // Check body/description - if strings.Contains(strings.ToLower(issue.Body), filter) { - return true - } - - // Check labels - for _, label := range issue.Labels { - if strings.Contains(strings.ToLower(label.Name), filter) { - return true - } - } - - // Check type - if strings.Contains(strings.ToLower(issue.Typename), filter) { - return true - } - - // Check author - if strings.Contains(strings.ToLower(issue.Author.Login), filter) { - return true - } - - // Check assignees - for _, assignee := range issue.Assignees { - if strings.Contains(strings.ToLower(assignee.Login), filter) { - return true - } - } - - return false -} - -func (m *model) getCurrentChoices() []ghapi.Issue { - if m.filterInput != "" { - return m.filteredChoices - } - return m.choices -} - -func (m *model) getOriginalIndex(filteredIndex int) int { - if m.filterInput != "" && filteredIndex < len(m.originalIndices) { - return m.originalIndices[filteredIndex] - } - return filteredIndex -} - -func fetchIssues(search string) tea.Cmd { - return func() tea.Msg { - issues, err := ghapi.GetIssues(search) - if err != nil { - return err - } - return issuesLoadedMsg{issues: issues, totalAvailable: len(issues), rawFetched: len(issues)} - } -} - -func fetchProjectItems(projectID, limit int) tea.Cmd { - return func() tea.Msg { - items, total, err := ghapi.GetProjectItemsWithTotal(projectID, limit) - if err != nil { - return err - } - issues := ghapi.ConvertItemsToIssues(items) - return issuesLoadedMsg{issues: issues, totalAvailable: total, rawFetched: limit} - } -} - -func fetchEstimatedItems(projectID, limit int) tea.Cmd { - return func() tea.Msg { - items, total, err := ghapi.GetEstimatedTicketsForProjectWithTotal(projectID, limit) - if err != nil { - return err - } - issues := ghapi.ConvertItemsToIssues(items) - // totalAvailable reflects total items in drafting project; rawFetched is the fetch limit - return issuesLoadedMsg{issues: issues, totalAvailable: total, rawFetched: limit} - } -} - -func fetchSprintItems(projectID, limit int) tea.Cmd { - return func() tea.Msg { - items, total, err := ghapi.GetCurrentSprintItemsWithTotal(projectID, limit) - if err != nil { - return err - } - issues := ghapi.ConvertItemsToIssues(items) - return issuesLoadedMsg{issues: issues, totalAvailable: total, rawFetched: limit} - } -} - -func (m model) Init() tea.Cmd { - var fetchCmd tea.Cmd - switch m.commandType { - case IssuesCommand: - fetchCmd = fetchIssues(m.search) - case ProjectCommand: - fetchCmd = fetchProjectItems(m.projectID, m.limit) - case EstimatedCommand: - fetchCmd = fetchEstimatedItems(m.projectID, m.limit) - case SprintCommand: - fetchCmd = fetchSprintItems(m.projectID, m.limit) - default: - fetchCmd = fetchIssues("") - } - return tea.Batch(fetchCmd, m.spinner.Tick) -} - -func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch m.workflowState { - case Loading: - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - } - return m, nil - case WorkflowRunning: - // Only allow quitting during workflow execution - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - } - return m, nil - case WorkflowComplete: - // Exit when workflow is complete - switch msg.String() { - case "q", "ctrl+c", "enter", "esc", " ": - return m, tea.Quit - } - return m, nil - case NormalMode: - switch msg.String() { - case "/": - m.workflowState = FilterInput - m.filterInput = "" - m.applyFilter() - case "w": - if len(m.selected) > 0 { - m.workflowState = WorkflowSelection - m.workflowCursor = 0 - m.errorMessage = "" - } - case "o": - currentChoices := m.getCurrentChoices() - if len(currentChoices) > 0 && m.cursor < len(currentChoices) { - // Get the original issue (not filtered) - originalIndex := m.getOriginalIndex(m.cursor) - if originalIndex < len(m.choices) { - issue := m.choices[originalIndex] - content := m.generateIssueContent(issue) - - if m.glamourRenderer != nil { - rendered, err := m.glamourRenderer.Render(content) - if err != nil { - // Fallback to plain content if rendering fails - m.issueContent = content - } else { - m.issueContent = rendered - } - } else { - m.issueContent = content - } - - m.detailViewport.SetContent(m.issueContent) - m.workflowState = IssueDetail - } - } - case "j", "down": - currentChoices := m.getCurrentChoices() - if m.cursor < len(currentChoices)-1 { - m.cursor++ - m.adjustViewForCursor() - } - case "k", "up": - if m.cursor > 0 { - m.cursor-- - m.adjustViewForCursor() - } - case "pgdown", "ctrl+f": - // Page down - move cursor by view height - currentChoices := m.getCurrentChoices() - newCursor := m.cursor + m.viewHeight - if newCursor >= len(currentChoices) { - newCursor = len(currentChoices) - 1 - } - m.cursor = newCursor - m.adjustViewForCursor() - case "pgup", "ctrl+b": - // Page up - move cursor by view height - newCursor := m.cursor - m.viewHeight - if newCursor < 0 { - newCursor = 0 - } - m.cursor = newCursor - m.adjustViewForCursor() - case "home", "ctrl+a": - // Go to first issue - m.cursor = 0 - m.adjustViewForCursor() - case "end", "ctrl+e": - // Go to last issue - currentChoices := m.getCurrentChoices() - m.cursor = len(currentChoices) - 1 - m.adjustViewForCursor() - case "enter", "x", " ": - currentChoices := m.getCurrentChoices() - if m.cursor < len(currentChoices) { - originalIndex := m.getOriginalIndex(m.cursor) - if _, exists := m.selected[originalIndex]; exists { - delete(m.selected, originalIndex) - } else { - m.selected[originalIndex] = struct{}{} - } - m.selectedCount = len(m.selected) - } - case "s": - // Toggle selection of related issues (parent/sub-tasks) - currentChoices := m.getCurrentChoices() - if m.cursor < len(currentChoices) { - issue := currentChoices[m.cursor] - // Load from cache or fetch - related, ok := m.relatedCache[issue.Number] - if !ok { - if rel, err := ghapi.GetRelatedIssueNumbers(issue.Number); err == nil { - related = rel - m.relatedCache[issue.Number] = related - } - } - related = append(related, issue.Number) - logger.Debugf("Related issues for #%d: %v", issue.Number, related) - if len(related) > 0 { - // Determine if all related currently selected - allSelected := true - for _, rn := range related { - // find index for issue number in m.choices - for idx, iss := range m.choices { - if iss.Number == rn { - if _, ok := m.selected[idx]; !ok { - allSelected = false - } - break - } - } - } - // Toggle accordingly - for _, rn := range related { - for idx, iss := range m.choices { - if iss.Number == rn { - if allSelected { - delete(m.selected, idx) - } else { - m.selected[idx] = struct{}{} - } - break - } - } - } - m.selectedCount = len(m.selected) - } - } - case "l": - // Select all visible (filtered) issues - m.selected = make(map[int]struct{}) - currentChoices := m.getCurrentChoices() - for i := range currentChoices { - orig := m.getOriginalIndex(i) - m.selected[orig] = struct{}{} - } - m.selectedCount = len(m.selected) - case "h": - // Clear all selections - m.selected = make(map[int]struct{}) - m.selectedCount = 0 - case "q", "ctrl+c": - return m, tea.Quit - } - case IssueDetail: - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "esc": - m.workflowState = NormalMode - case "j", "down": - m.detailViewport.LineDown(1) - case "k", "up": - m.detailViewport.LineUp(1) - case "pgdown", "ctrl+f": - m.detailViewport.HalfViewDown() - case "pgup", "ctrl+b": - m.detailViewport.HalfViewUp() - case "home", "ctrl+a": - m.detailViewport.GotoTop() - case "end", "ctrl+e": - m.detailViewport.GotoBottom() - } - case FilterInput: - switch msg.String() { - case "esc": - m.workflowState = NormalMode - m.filterInput = "" - m.applyFilter() - m.cursor = 0 - m.adjustViewForCursor() - case "enter": - m.workflowState = NormalMode - m.adjustViewForCursor() - case "backspace": - if len(m.filterInput) > 0 { - m.filterInput = m.filterInput[:len(m.filterInput)-1] - m.applyFilter() - m.cursor = 0 - m.adjustViewForCursor() - } - case "q", "ctrl+c": - return m, tea.Quit - default: - // Add character to filter - if len(msg.String()) == 1 { - m.filterInput += msg.String() - m.applyFilter() - m.cursor = 0 - m.adjustViewForCursor() - } - } - case WorkflowSelection: - switch msg.String() { - case "j", "down": - if m.workflowCursor < len(WorkflowTypeValues)-1 { - m.workflowCursor++ - } - case "k", "up": - if m.workflowCursor > 0 { - m.workflowCursor-- - } - case "enter": - m.workflowType = WorkflowType(m.workflowCursor) - switch m.workflowType { - case BulkAddLabel, BulkRemoveLabel: - m.workflowState = LabelInput - m.labelInput = "" - case BulkDemoSummary: - return m, m.executeWorkflow() - case BulkSprintKickoff, BulkKickOutOfSprint: - if m.projectID != 0 { - // Use the provided project ID - return m, m.executeWorkflow() - } else { - m.workflowState = ProjectInput - m.projectInput = "" - } - case BulkMilestoneClose: - return m, m.executeWorkflow() - } - case "esc": - m.workflowState = NormalMode - m.errorMessage = "" - } - case LabelInput: - switch msg.String() { - case "enter": - if m.labelInput != "" { - return m, m.executeWorkflow() - } - case "esc": - m.workflowState = NormalMode - m.errorMessage = "" - case "backspace": - if len(m.labelInput) > 0 { - m.labelInput = m.labelInput[:len(m.labelInput)-1] - } - default: - // Add character to input - if len(msg.String()) == 1 { - m.labelInput += msg.String() - } - } - case ProjectInput: - switch msg.String() { - case "enter": - if m.projectInput != "" { - return m, m.executeWorkflow() - } - case "esc": - m.workflowState = NormalMode - m.errorMessage = "" - case "backspace": - if len(m.projectInput) > 0 { - m.projectInput = m.projectInput[:len(m.projectInput)-1] - } - default: - // Add character to input - if len(msg.String()) == 1 { - m.projectInput += msg.String() - } - } - } - case issuesLoadedMsg: - m.choices = msg.issues - m.totalCount = len(m.choices) - m.totalAvailable = msg.totalAvailable - m.rawFetchedCount = msg.rawFetched - m.workflowState = NormalMode - m.applyFilter() // Initialize filter state - m.adjustViewForCursor() // Ensure view is properly initialized - return m, nil - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - cmds = append(cmds, cmd) - case progress.FrameMsg: - progressModel, cmd := m.overallProgress.Update(msg) - m.overallProgress = progressModel.(progress.Model) - cmds = append(cmds, cmd) - case tea.WindowSizeMsg: - // Update viewport size when window is resized - m.detailViewport.Width = msg.Width - 4 - m.detailViewport.Height = msg.Height - 6 - case processTaskMsg: - // This case is no longer used since we've moved to AsyncManager - // Ignore these messages - return m, nil - case startAsyncWorkflowMsg: - if m.workflowState == WorkflowRunning { - // Store the actions and start AsyncManager - m.currentActions = msg.actions - - // Create status channel - m.statusChan = make(chan ghapi.Status) - - // Start AsyncManager in a goroutine - go ghapi.AsyncManager(msg.actions, m.statusChan) - - // Return command to listen for status updates - return m, m.listenForAsyncStatus() - } - case actionStatusMsg: - if m.workflowState == WorkflowRunning && msg.index < len(m.tasks) { - // Update the task based on the action status - if msg.state == "success" { - m.tasks[msg.index].Status = TaskSuccess - m.tasks[msg.index].Progress = 1.0 - } else if msg.state == "error" { - m.tasks[msg.index].Status = TaskError - m.tasks[msg.index].Progress = 0.0 - } - m.currentTask = msg.index - - // Update overall progress - completedTasks := 0 - for _, task := range m.tasks { - if task.Status == TaskSuccess { - completedTasks++ - } - } - overallPercent := float64(completedTasks) / float64(len(m.tasks)) - cmds = append(cmds, m.overallProgress.SetPercent(overallPercent)) - - // Check if we have an error - if msg.state == "error" { - m.workflowState = WorkflowComplete - m.errorMessage = fmt.Sprintf("Task %d failed", msg.index) - } else if completedTasks == len(m.tasks) { - // All tasks completed successfully - m.workflowState = WorkflowComplete - } - // Note: Sequential processing is now handled by AsyncManager, - // so we don't trigger next action processing here - } - case asyncStatusMsg: - if msg.status.State == "done" { - // AsyncManager is finished, all actions completed - m.workflowState = WorkflowComplete - m.statusChan = nil - return m, nil - } - - if m.workflowState == WorkflowRunning && msg.status.Index < len(m.tasks) { - // Update the task based on the async status - if msg.status.State == "success" { - m.tasks[msg.status.Index].Status = TaskSuccess - m.tasks[msg.status.Index].Progress = 1.0 - } else if msg.status.State == "error" { - m.tasks[msg.status.Index].Status = TaskError - m.tasks[msg.status.Index].Progress = 0.0 - } - m.currentTask = msg.status.Index + 1 - - // Update overall progress - completedTasks := 0 - for _, task := range m.tasks { - if task.Status == TaskSuccess { - completedTasks++ - } - } - overallPercent := float64(completedTasks) / float64(len(m.tasks)) - cmds = append(cmds, m.overallProgress.SetPercent(overallPercent)) - - // Check if we have an error - if msg.status.State == "error" { - m.workflowState = WorkflowComplete - m.errorMessage = fmt.Sprintf("Task %d failed", msg.status.Index) - // Channel will be closed by AsyncManager - m.statusChan = nil - } else if completedTasks == len(m.tasks) { - // All tasks completed successfully - m.workflowState = WorkflowComplete - // Channel will be closed by AsyncManager - m.statusChan = nil - } else { - // Continue listening for more status updates - cmds = append(cmds, m.listenForAsyncStatus()) - } - } - case taskUpdateMsg: - if msg.taskID < len(m.tasks) { - m.tasks[msg.taskID].Progress = msg.progress - m.tasks[msg.taskID].Status = msg.status - if msg.err != nil { - m.tasks[msg.taskID].Error = msg.err - } - m.currentTask = msg.taskID - - // If this is an error, mark workflow as complete - if msg.status == TaskError { - m.workflowState = WorkflowComplete - m.errorMessage = fmt.Sprintf("Task failed: %v", msg.err) - cmds = append(cmds, m.overallProgress.SetPercent(1.0)) - } else { - // Update overall progress - totalProgress := float64(0) - completedTasks := 0 - for _, task := range m.tasks { - totalProgress += task.Progress - if task.Status == TaskSuccess { - completedTasks++ - } - } - overallPercent := totalProgress / float64(len(m.tasks)) - cmds = append(cmds, m.overallProgress.SetPercent(overallPercent)) - - // Check if all tasks are completed successfully - if completedTasks == len(m.tasks) { - m.workflowState = WorkflowComplete - // Will show success summary in the view - } else if msg.status == TaskSuccess && (m.workflowType == BulkAddLabel || m.workflowType == BulkRemoveLabel) { - // For bulk label operations, trigger the next task with a delay to ensure serialization - nextTaskID := msg.taskID + 1 - if nextTaskID < len(m.tasks) { - cmds = append(cmds, func() tea.Msg { - // Add a small delay before processing the next task - time.Sleep(50 * time.Millisecond) - return processTaskMsg{taskID: nextTaskID} - }) - } - } - } - } - case workflowExitMsg: - m.exitMessage = msg.message - return m, tea.Quit - case workflowCompleteMsg: - m.workflowState = WorkflowComplete - if !msg.success { - m.errorMessage = msg.message - } - // Set overall progress to 100% - cmds = append(cmds, m.overallProgress.SetPercent(1.0)) - case workflowResultMsg: - // Legacy handler - convert to new system - if msg.err != nil { - m.workflowState = WorkflowComplete - m.errorMessage = fmt.Sprintf("Error: %v", msg.err) - } else { - m.workflowState = WorkflowComplete - } - cmds = append(cmds, m.overallProgress.SetPercent(1.0)) - } - - return m, tea.Batch(cmds...) -} - -func truncTitle(title string) string { - if len(title) > 50 { - return title[:47] + "..." - } - return title -} - -// prioritize labels 'story', 'bug', ':release', ':product', '#g-mdm', '#g-orchestration', -// '#g-software' -// show a maximum of 30 characters, if longer, truncate and add '...+n' where n is the number of additional labels -func truncLables(labels []ghapi.Label) string { - if len(labels) == 0 { - return fmt.Sprintf("%-35s", "- No Labels -") - } - priorityLabels := map[string]bool{"story": true, "bug": true, ":release": true, ":product": true, "#g-mdm": true, "#g-orchestration": true, "#g-software": true} - var labelNames []string - for _, label := range labels { - labelNames = append(labelNames, label.Name) - } - displayLabels := []string{} - secondaryLabels := []string{} - for _, label := range labelNames { - if priorityLabels[label] { - displayLabels = append(displayLabels, label) - } else { - secondaryLabels = append(secondaryLabels, label) - } - } - if len(secondaryLabels) > 0 { - displayLabels = append(displayLabels, secondaryLabels...) - } - countDisplay := 0 - sumCharacters := 0 - for _, label := range displayLabels { - if (sumCharacters + len(label)) > 30 { - break - } - countDisplay += 1 - sumCharacters += len(label) + 2 - } - extraLabels := 0 - if countDisplay < len(displayLabels) { - extraLabels = len(displayLabels) - countDisplay - } - extraString := "" - if extraLabels > 0 { - extraString = fmt.Sprintf("...+%d", extraLabels) - } - return fmt.Sprintf("%-35s", fmt.Sprintf("%s%s", strings.Join(displayLabels[:countDisplay], ", "), extraString)) -} - -func truncEstimate(estimate int) string { - estString := fmt.Sprintf("%d", estimate) - if estimate == 0 { - estString = "-" - } - return fmt.Sprintf("%-9s", estString) -} - -func truncType(typename string) string { - if typename == "" { - typename = "-" - } - // Return the typename with spaces filling out to 10 characters - if len(typename) < 10 { - return fmt.Sprintf("%-10s", typename) - } - return strings.TrimSpace(typename[:10]) -} - -func (m *model) adjustViewForCursor() { - currentChoices := m.getCurrentChoices() - - // Ensure cursor is within bounds - if len(currentChoices) == 0 { - m.cursor = 0 - m.viewOffset = 0 - return - } - - if m.cursor < 0 { - m.cursor = 0 - } - if m.cursor >= len(currentChoices) { - m.cursor = len(currentChoices) - 1 - } - - // Adjust view offset to keep cursor visible - if m.cursor < m.viewOffset { - // Cursor is above visible area, scroll up - m.viewOffset = m.cursor - } else if m.cursor >= m.viewOffset+m.viewHeight { - // Cursor is below visible area, scroll down - m.viewOffset = m.cursor - m.viewHeight + 1 - } - - // Ensure view offset doesn't go negative or beyond available items - if m.viewOffset < 0 { - m.viewOffset = 0 - } - maxOffset := len(currentChoices) - m.viewHeight - if maxOffset < 0 { - maxOffset = 0 - } - if m.viewOffset > maxOffset { - m.viewOffset = maxOffset - } -} - -func (m model) View() string { - s := "" - switch m.workflowState { - case Loading: - var loadingMessage string - switch m.commandType { - case IssuesCommand: - loadingMessage = "Fetching Issues..." - case ProjectCommand: - loadingMessage = fmt.Sprintf("Fetching Project Items (ID: %d)...", m.projectID) - case EstimatedCommand: - loadingMessage = fmt.Sprintf("Fetching Estimated Tickets (Project: %d)...", m.projectID) - default: - loadingMessage = "Fetching Issues..." - } - return fmt.Sprintf("\n%s %s\n\n", m.spinner.View(), loadingMessage) - - case WorkflowRunning: - s = "\nπŸš€ Workflow Execution in Progress\n\n" - - // Show overall progress - s += fmt.Sprintf("Overall Progress: %s\n\n", m.overallProgress.View()) - - // Determine window of tasks to display (show most recent 10, auto-scroll) - totalTasks := len(m.tasks) - lastFinished := -1 - for i := range m.tasks { - if m.tasks[i].Status == TaskSuccess || m.tasks[i].Status == TaskError { - lastFinished = i - } - } - // Prefer to keep the currently running task in view - lastIdx := lastFinished - if m.currentTask > lastIdx { - lastIdx = m.currentTask - } - if lastIdx < 0 { - lastIdx = 0 - } - windowSize := 10 - start := lastIdx - windowSize + 1 - if start < 0 { - start = 0 - } - end := start + windowSize - if end > totalTasks { - end = totalTasks - } - - // Show individual task progress (windowed) - if start > 0 { - s += fmt.Sprintf("... %d earlier task(s) above ...\n", start) - } - for i := start; i < end; i++ { - task := m.tasks[i] - var statusIcon string - var statusText string - - switch task.Status { - case TaskPending: - statusIcon = "⏳" - statusText = pendingStyle.Render("PENDING") - case TaskInProgress: - statusIcon = "πŸ”„" - statusText = statusStyle.Render("RUNNING") - case TaskSuccess: - statusIcon = "βœ…" - statusText = successStyle.Render("SUCCESS") - case TaskError: - statusIcon = "❌" - statusText = errorStyle.Render("ERROR") - } - - // Override icon for current task to show it's active - if i == m.currentTask && task.Status != TaskSuccess && task.Status != TaskError { - statusIcon = "πŸƒβ€β™€οΈ" - } - - s += fmt.Sprintf("%s %s %s", statusIcon, statusText, task.Description) - - if task.Error != nil { - s += fmt.Sprintf(" - Error: %v", task.Error) - } - s += "\n" - } - if end < totalTasks { - s += fmt.Sprintf("... %d more task(s) below ...\n", totalTasks-end) - } - - // Add progress counter at the bottom - completedTasks := 0 - for _, task := range m.tasks { - if task.Status == TaskSuccess { - completedTasks++ - } - if task.Status == TaskError { - completedTasks++ - } - } - - progressPercent := 0.0 - if totalTasks > 0 { - progressPercent = float64(completedTasks) / float64(totalTasks) * 100 - } - - s += fmt.Sprintf("\nProgress: %d/%d tasks completed (%.1f%%)", completedTasks, totalTasks, progressPercent) - s += "\n\nPress 'q' to quit" - return s - - case WorkflowComplete: - s = "\nπŸŽ‰ Workflow Complete!\n\n" - - // Show final progress (should be 100%) - s += fmt.Sprintf("Final Progress: %s\n\n", m.overallProgress.View()) - - // Show final task statuses - successCount := 0 - errorCount := 0 - for _, task := range m.tasks { - var statusIcon string - var statusText string - - switch task.Status { - case TaskSuccess: - statusIcon = "βœ…" - statusText = successStyle.Render("SUCCESS") - successCount++ - case TaskError: - statusIcon = "❌" - statusText = errorStyle.Render("ERROR") - errorCount++ - default: - statusIcon = "⚠️" - statusText = pendingStyle.Render("PENDING") - } - - s += fmt.Sprintf("%s %s %s", statusIcon, statusText, task.Description) - if task.Error != nil { - s += fmt.Sprintf(" - Error: %v", task.Error) - } - s += "\n" - } - - s += fmt.Sprintf("\nSummary: %d successful, %d failed out of %d tasks\n", successCount, errorCount, len(m.tasks)) - - if m.errorMessage != "" { - s += fmt.Sprintf("\n%s\n", m.errorMessage) - } - - s += "\nPress any key to exit..." - return s - - case NormalMode: - currentChoices := m.getCurrentChoices() - currentPos := m.cursor + 1 - totalFiltered := len(currentChoices) - - // Compute sum of estimates for currently selected issues - sumSelectedEstimates := 0 - for idx := range m.selected { - if idx >= 0 && idx < len(m.choices) { - sumSelectedEstimates += m.choices[idx].Estimate - } - } - - // Header with filter info - headerText := "" - if m.filterInput != "" { - headerText = fmt.Sprintf("GitHub Issues (%d/%d, Ξ£est sel=%d) - Filtered by: '%s':\n\n", currentPos, totalFiltered, sumSelectedEstimates, m.filterInput) - } else { - headerText = fmt.Sprintf("GitHub Issues (%d/%d, Ξ£est sel=%d):\n\n", currentPos, m.totalCount, sumSelectedEstimates) - } - - warningBanner := "" - if m.totalAvailable > 0 && m.totalAvailable > m.rawFetchedCount { - missing := m.totalAvailable - m.rawFetchedCount - warningBanner = errorStyle.Render(fmt.Sprintf("⚠ %d items not shown (limit=%d, total=%d). Increase --limit to include all issues.", missing, m.rawFetchedCount, m.totalAvailable)) + "\n\n" - } - - s = warningBanner + headerText + fmt.Sprintf(" %-2d/%-2d Number Estimate Type Labels Title\n", - m.selectedCount, m.totalCount) - - // Show indicator if there are issues above the visible area - if m.viewOffset > 0 { - s += fmt.Sprintf(" %s %-6s %s %s %s %s\n", - " ", "...", " ", " ", strings.Repeat(" ", 35), "More issues above") - } - - // Calculate which issues to display - startIdx := m.viewOffset - endIdx := m.viewOffset + m.viewHeight - if endIdx > len(currentChoices) { - endIdx = len(currentChoices) - } - - // Display visible issues - for i := startIdx; i < endIdx; i++ { - issue := currentChoices[i] - originalIndex := m.getOriginalIndex(i) - cursor := " " - if i == m.cursor { - cursor = ">" - } - selected := "" - if _, exists := m.selected[originalIndex]; exists { - selected = "[x] " - } else { - selected = "[ ] " - } - s += fmt.Sprintf("%s %s %s %s %s %s %s\n", - cursor, selected, fmt.Sprintf("%-6d", issue.Number), truncEstimate(issue.Estimate), - truncType(issue.Typename), truncLables(issue.Labels), truncTitle(issue.Title)) - } - - // Show indicator if there are issues below the visible area - if m.viewOffset+m.viewHeight < len(currentChoices) { - s += fmt.Sprintf(" %s %-6s %s %s %s %s\n", - " ", "...", " ", " ", strings.Repeat(" ", 35), "More issues below") - } - - s += "\nNavigation: ↑/↓ or j/k (line), PgUp/PgDn (page), Home/End (top/bottom)\n" - s += "Actions: enter/space/x (select), 'l' select all, 'h' deselect all, 'o' (view details), '/' (filter), 'w' (workflow), 'q' (quit)\n" - case IssueDetail: - if len(m.choices) > 0 && m.cursor < len(m.choices) { - issue := m.choices[m.cursor] - s += fmt.Sprintf("Issue #%d Details\n\n", issue.Number) - s += m.detailViewport.View() - s += "\n\nNavigation: ↑/↓ or j/k (line), PgUp/PgDn (page), Home/End (top/bottom)\n" - s += "Actions: 'esc' (back to list), 'q' (quit)\n" - } else { - s = "No issue selected\n" - } - case FilterInput: - currentChoices := m.getCurrentChoices() - totalFiltered := len(currentChoices) - - s = fmt.Sprintf("Filter Issues - %d results:\n", totalFiltered) - s += fmt.Sprintf("Filter: %s_\n\n", m.filterInput) - - // Show a preview of filtered results (top 10) - previewCount := 10 - if totalFiltered < previewCount { - previewCount = totalFiltered - } - - s += "Preview:\n" - for i := 0; i < previewCount; i++ { - issue := currentChoices[i] - originalIndex := m.getOriginalIndex(i) - selected := "" - if _, exists := m.selected[originalIndex]; exists { - selected = "[x] " - } else { - selected = "[ ] " - } - s += fmt.Sprintf(" %s #%-6d %s\n", selected, issue.Number, truncTitle(issue.Title)) - } - - if totalFiltered > previewCount { - s += fmt.Sprintf(" ... and %d more\n", totalFiltered-previewCount) - } - - s += "\nType to filter by number, title, labels, or description\n" - s += "Actions: 'enter' (apply filter), 'esc' (cancel), 'q' (quit)\n" - case WorkflowSelection: - s = "\n--- Workflow Selection ---\n" - workflows := WorkflowTypeValues - for i, workflow := range workflows { - cursor := " " - if i == m.workflowCursor { - cursor = ">" - } - selected := "( )" - if i == m.workflowCursor { - selected = "(*)" - } - s += fmt.Sprintf("%s %s %s\n", cursor, selected, workflow) - } - s += "\nPress 'enter' to select, 'esc' to cancel.\n" - case LabelInput: - workflowName := "Add Label" - if m.workflowType == BulkRemoveLabel { - workflowName = "Remove Label" - } - s = fmt.Sprintf("\n--- %s ---\n", workflowName) - s += fmt.Sprintf("Label: %s_\n", m.labelInput) - s += "Press 'enter' to execute, 'esc' to cancel.\n" - case ProjectInput: - workflowTitle := "Sprint Kickoff" - promptText := "Target Project (ID or alias):" - if m.workflowType == BulkKickOutOfSprint { - workflowTitle = "Kick Out Of Sprint" - promptText = "Source Project (ID or alias):" - } - s = fmt.Sprintf("\n--- %s ---\n", workflowTitle) - s += fmt.Sprintf("%s %s_\n", promptText, m.projectInput) - s += "Press 'enter' to execute, 'esc' to cancel.\n" - } - - if m.errorMessage != "" && m.workflowState != WorkflowComplete { - s += fmt.Sprintf("\n%s\n", m.errorMessage) - } - - return s -} - -func (m *model) executeWorkflow() tea.Cmd { - // Initialize workflow tasks - m.workflowState = WorkflowRunning - m.currentTask = 0 - m.tasks = []WorkflowTask{} - - // Collect selected issues - var selectedIssues []ghapi.Issue - for i := range m.selected { - if i < len(m.choices) { - selectedIssues = append(selectedIssues, m.choices[i]) - } - } - - if len(selectedIssues) == 0 { - return func() tea.Msg { - return workflowCompleteMsg{ - success: false, - message: "No issues selected", - } - } - } - - // Create actions and tasks based on workflow type - var actions []ghapi.Action - switch m.workflowType { - case BulkDemoSummary: - // Build summary markdown grouped by label category and assignee - features := map[string][]ghapi.Issue{} - bugs := map[string][]ghapi.Issue{} - // if status lower ends with 'ready' - assigneeOrUnassigned := func(issue ghapi.Issue) string { - if len(issue.Assignees) > 0 { - return issue.Assignees[0].Login - } - return "unassigned" - } - for _, issue := range selectedIssues { - if issue.Status == "" || strings.HasSuffix(strings.ToLower(issue.Status), "ready") { - continue - // Do something - } - isFeature := false - isBug := false - for _, l := range issue.Labels { - if l.Name == "story" { - isFeature = true - } - if l.Name == "bug" { - isBug = true - } - if l.Name == "~unreleased bug" { - isBug = false - } - } - assignee := assigneeOrUnassigned(issue) - if isFeature { - features[assignee] = append(features[assignee], issue) - } - if isBug { - bugs[assignee] = append(bugs[assignee], issue) - } - } - // Generate markdown content - var builder strings.Builder - builder.WriteString("## Features Completed\n\n") - if len(features) == 0 { - builder.WriteString("_None_\n\n") - } - var featureAssignees []string - for a := range features { - featureAssignees = append(featureAssignees, a) - } - sort.Strings(featureAssignees) - for _, a := range featureAssignees { - builder.WriteString(fmt.Sprintf("%s\n", a)) - for _, issue := range features[a] { - builder.WriteString(fmt.Sprintf("[#%d](https://github.com/fleetdm/fleet/issues/%d)%s\n", issue.Number, issue.Number, issue.Title)) - } - builder.WriteString("\n") - } - builder.WriteString("## Bugs Completed\n\n") - if len(bugs) == 0 { - builder.WriteString("_None_\n\n") - } - var bugAssignees []string - for a := range bugs { - bugAssignees = append(bugAssignees, a) - } - sort.Strings(bugAssignees) - for _, a := range bugAssignees { - builder.WriteString(fmt.Sprintf("%s\n", a)) - for _, issue := range bugs[a] { - builder.WriteString(fmt.Sprintf("[#%d](https://github.com/fleetdm/fleet/issues/%d)%s\n", issue.Number, issue.Number, issue.Title)) - } - builder.WriteString("\n") - } - return func() tea.Msg { return workflowExitMsg{success: true, message: builder.String()} } - case BulkAddLabel: - actions = ghapi.CreateBulkAddLableAction(selectedIssues, m.labelInput) - // Create individual tasks for each issue - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: i, - Description: fmt.Sprintf("Adding label '%s' to issue #%d", m.labelInput, issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - case BulkRemoveLabel: - actions = ghapi.CreateBulkRemoveLabelAction(selectedIssues, m.labelInput) - // Create individual tasks for each issue - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: i, - Description: fmt.Sprintf("Removing label '%s' from issue #%d", m.labelInput, issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - case BulkSprintKickoff: - projectID := m.projectID - if projectID == 0 && m.projectInput != "" { - // Try to resolve project ID - resolvedID, err := ghapi.ResolveProjectID(m.projectInput) - if err != nil { - return func() tea.Msg { - return workflowCompleteMsg{ - success: false, - message: fmt.Sprintf("Failed to resolve project ID: %v", err), - } - } - } - projectID = resolvedID - } - - actions = ghapi.CreateBulkSprintKickoffActions(selectedIssues, ghapi.Aliases["draft"], projectID) - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: i, - Description: fmt.Sprintf("Adding #%d issue to project %d", issue.Number, projectID), - Status: TaskPending, - Progress: 0.0, - }) - } - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: len(selectedIssues) + i, - Description: fmt.Sprintf("Adding ':release' label to #%d issue", issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: (2 * len(selectedIssues)) + i, - Description: fmt.Sprintf("Syncing estimate fields for #%d issue", issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: (3 * len(selectedIssues)) + i, - Description: fmt.Sprintf("Setting current sprint for #%d issue in project %d", issue.Number, projectID), - Status: TaskPending, - Progress: 0.0, - }) - } - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: (4 * len(selectedIssues)) + i, - Description: fmt.Sprintf("Removing ':product' label from #%d issue", issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: (5 * len(selectedIssues)) + i, - Description: fmt.Sprintf("Removing #%d issue from drafting project", issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - case BulkMilestoneClose: - actions = ghapi.CreateBulkMilestoneCloseActions(selectedIssues) - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: len(selectedIssues) + i, - Description: fmt.Sprintf("Adding #%d issue to drafting project", issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: len(selectedIssues) + i, - Description: fmt.Sprintf("Adding ':product' label to #%d issue", issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: len(selectedIssues) + i, - Description: fmt.Sprintf("Setting status to 'confirm and celebrate' for #%d issue", issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: len(selectedIssues) + i, - Description: fmt.Sprintf("Removing ':release' label from #%d issue", issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - case BulkKickOutOfSprint: - projectID := m.projectID - if projectID == 0 && m.projectInput != "" { - // Try to resolve project ID - resolvedID, err := ghapi.ResolveProjectID(m.projectInput) - if err != nil { - return func() tea.Msg { - return workflowCompleteMsg{ - success: false, - message: fmt.Sprintf("Failed to resolve project ID: %v", err), - } - } - } - projectID = resolvedID - } - - actions = ghapi.CreateBulkKickOutOfSprintActions(selectedIssues, projectID) - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: i, - Description: fmt.Sprintf("Adding #%d issue to drafting project", issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: len(selectedIssues) + i, - Description: fmt.Sprintf("Setting status to 'estimated' for #%d issue", issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: (2 * len(selectedIssues)) + i, - Description: fmt.Sprintf("Syncing estimate from project %d for #%d issue", projectID, issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: (3 * len(selectedIssues)) + i, - Description: fmt.Sprintf("Adding ':product' label to #%d issue", issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: (4 * len(selectedIssues)) + i, - Description: fmt.Sprintf("Removing ':release' label from #%d issue", issue.Number), - Status: TaskPending, - Progress: 0.0, - }) - } - for i, issue := range selectedIssues { - m.tasks = append(m.tasks, WorkflowTask{ - ID: (4 * len(selectedIssues)) + i, - Description: fmt.Sprintf("Removing #%d issue from project %d", issue.Number, projectID), - Status: TaskPending, - Progress: 0.0, - }) - } - } - - // For all workflows, start async workflow - return m.executeAsyncWorkflow(actions) -} - -func (m *model) listenForAsyncStatus() tea.Cmd { - return func() tea.Msg { - status, ok := <-m.statusChan - if !ok { - // Channel is closed, AsyncManager is done - return asyncStatusMsg{status: ghapi.Status{Index: -1, State: "done"}} - } - return asyncStatusMsg{status: status} - } -} - -func (m *model) executeAsyncWorkflow(actions []ghapi.Action) tea.Cmd { - return func() tea.Msg { - return startAsyncWorkflowMsg{actions: actions} - } -} diff --git a/tools/github-manage/pkg/ghapi/models.go b/tools/github-manage/pkg/ghapi/models.go index 54b981778d..63a9c4c93f 100644 --- a/tools/github-manage/pkg/ghapi/models.go +++ b/tools/github-manage/pkg/ghapi/models.go @@ -109,6 +109,21 @@ type ProjectField struct { Options []ProjectFieldOption `json:"options,omitempty"` } +type User struct { + Login string `json:"login"` + ID int `json:"id"` + NodeID string `json:"node_id"` + AvatarURL string `json:"avatar_url"` + HTMLURL string `json:"html_url"` + Name string `json:"name"` + Type string `json:"type"` + GravatarID string `json:"gravatar_id"` + URL string `json:"url"` + Company string `json:"company"` + UserViewType string `json:"user_view_type"` + SiteAdmin bool `json:"site_admin"` +} + func ConvertItemsToIssues(items []ProjectItem) []Issue { var issues []Issue for _, item := range items { diff --git a/tools/github-manage/pkg/ghapi/user.go b/tools/github-manage/pkg/ghapi/user.go new file mode 100644 index 0000000000..7049f6072b --- /dev/null +++ b/tools/github-manage/pkg/ghapi/user.go @@ -0,0 +1,47 @@ +package ghapi + +import ( + "encoding/json" + "fmt" +) + +var username_mapping = map[string]string{} + +// ParseJSONtoUser converts JSON data to a slice of Issue structs. +func ParseJSONtoUser(jsonData []byte) (User, error) { + var user User + err := json.Unmarshal(jsonData, &user) + if err != nil { + return User{}, err + } + return user, nil +} + +// GetIssues fetches issues from GitHub using optional search criteria. +func GetUserName(userLogin string) (User, error) { + var user User + + if fullName, ok := username_mapping[userLogin]; ok { + user.Login = userLogin + user.Name = fullName + return user, nil + } + + command := fmt.Sprintf("gh api /users/%s", userLogin) + + results, err := RunCommandAndReturnOutput(command) + if err != nil { + return User{}, err + } + user, err = ParseJSONtoUser(results) + if err != nil { + return User{}, err + } + username_mapping[user.Login] = user.Name + return user, nil +} + +func init() { + // Ensure any package-level initialization here if needed + username_mapping = make(map[string]string) +} diff --git a/tools/github-manage/pkg/ghapi/workflows.go b/tools/github-manage/pkg/ghapi/workflows.go index b74b9efc80..7793d624e9 100644 --- a/tools/github-manage/pkg/ghapi/workflows.go +++ b/tools/github-manage/pkg/ghapi/workflows.go @@ -5,6 +5,7 @@ import "fleetdm/gm/pkg/logger" // ActionType represents the type of action to be performed on an issue. type ActionType string +// newworkflow new actions added must have a new actiontype const ( ATAddLabel ActionType = "add_label" ATRemoveLabel ActionType = "remove_label" @@ -32,6 +33,7 @@ type Status struct { State string `json:"state"` } +// newworkflow new workflow 'action builder' functions are here // CreateBulkAddLableAction creates actions to add a label to multiple issues. func CreateBulkAddLableAction(issues []Issue, label string) []Action { var actions []Action @@ -177,6 +179,7 @@ func AsyncManager(actions []Action, statusChan chan<- Status) { logger.Infof("Processing action %d/%d: %s for issue #%d", i+1, len(actions), action.Type, action.Issue.Number) switch action.Type { + // newworkflow new actions must be supported in this switch case ATAddLabel: err := AddLabelToIssue(action.Issue.Number, action.Label) if err != nil { @@ -254,6 +257,11 @@ func AsyncManager(actions []Action, statusChan chan<- Status) { logger.Info("AsyncManager completed all actions") } +// +// IMPORTANT: TUI workflows should use the Async methods above instead of the synchronous methods below +// + +// These methods are more for testing individual steps w/o tui. // BulkAddLabel adds a label to multiple issues. func BulkAddLabel(issues []Issue, label string) error { logger.Infof("Adding label '%s' to %d issues", label, len(issues)) diff --git a/tools/github-manage/pkg/tui/filter.go b/tools/github-manage/pkg/tui/filter.go new file mode 100644 index 0000000000..84659103bd --- /dev/null +++ b/tools/github-manage/pkg/tui/filter.go @@ -0,0 +1,105 @@ +package tui + +import ( + "fmt" + "strings" + + "fleetdm/gm/pkg/ghapi" +) + +func (m *model) applyFilter() { + if m.filterInput == "" { + // No filter, show all issues + // Ensure base list is sorted + ghapi.SortIssuesForDisplay(m.choices) + m.filteredChoices = m.choices + m.originalIndices = make([]int, len(m.choices)) + for i := range m.originalIndices { + m.originalIndices[i] = i + } + return + } + + filter := strings.ToLower(m.filterInput) + m.filteredChoices = nil + m.originalIndices = nil + + // Ensure base list is sorted before applying filter + ghapi.SortIssuesForDisplay(m.choices) + for i, issue := range m.choices { + if m.matchesFilter(issue, filter) { + m.filteredChoices = append(m.filteredChoices, issue) + m.originalIndices = append(m.originalIndices, i) + } + } + + // Reset cursor if it's beyond the filtered results + if m.cursor >= len(m.filteredChoices) { + m.cursor = 0 + } +} + +// --- Sorting helpers for Normal view ordering --- + +// labelNamesLower returns a set of lowercase label names for fast lookup. +// Sorting moved to ghapi.SortIssuesForDisplay + +func (m *model) matchesFilter(issue ghapi.Issue, filter string) bool { + // Check issue number + if strings.Contains(strings.ToLower(fmt.Sprintf("#%d", issue.Number)), filter) { + return true + } + if strings.Contains(strings.ToLower(fmt.Sprintf("%d", issue.Number)), filter) { + return true + } + + // Check title + if strings.Contains(strings.ToLower(issue.Title), filter) { + return true + } + + // Check body/description + if strings.Contains(strings.ToLower(issue.Body), filter) { + return true + } + + // Check labels + for _, label := range issue.Labels { + if strings.Contains(strings.ToLower(label.Name), filter) { + return true + } + } + + // Check type + if strings.Contains(strings.ToLower(issue.Typename), filter) { + return true + } + + // Check author + if strings.Contains(strings.ToLower(issue.Author.Login), filter) { + return true + } + + // Check assignees + for _, assignee := range issue.Assignees { + if strings.Contains(strings.ToLower(assignee.Login), filter) { + return true + } + } + + return false +} + +func (m *model) getCurrentChoices() []ghapi.Issue { + if m.filterInput != "" { + return m.filteredChoices + } + return m.choices +} + +func (m *model) getOriginalIndex(filteredIndex int) int { + if m.filterInput != "" && filteredIndex < len(m.originalIndices) { + return m.originalIndices[filteredIndex] + } + return filteredIndex +} diff --git a/tools/github-manage/pkg/tui/hotkeys.go b/tools/github-manage/pkg/tui/hotkeys.go new file mode 100644 index 0000000000..f9f9577388 --- /dev/null +++ b/tools/github-manage/pkg/tui/hotkeys.go @@ -0,0 +1,306 @@ +package tui + +import ( + "fleetdm/gm/pkg/ghapi" + "fleetdm/gm/pkg/logger" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m *model) HandleHotkeys(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch m.workflowState { + case Loading: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + } + return m, nil + case WorkflowRunning: + // Only allow quitting during workflow execution + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + } + return m, nil + case WorkflowComplete: + // Exit when workflow is complete + switch msg.String() { + case "q", "ctrl+c", "enter", "esc", " ": + return m, tea.Quit + } + return m, nil + case NormalMode: + switch msg.String() { + case "/": + m.workflowState = FilterInput + m.filterInput = "" + m.applyFilter() + case "w": + if len(m.selected) > 0 { + m.workflowState = WorkflowSelection + m.workflowCursor = 0 + m.errorMessage = "" + } + case "o": + currentChoices := m.getCurrentChoices() + if len(currentChoices) > 0 && m.cursor < len(currentChoices) { + // Get the original issue (not filtered) + originalIndex := m.getOriginalIndex(m.cursor) + if originalIndex < len(m.choices) { + issue := m.choices[originalIndex] + content := m.generateIssueContent(issue) + + if m.glamourRenderer != nil { + rendered, err := m.glamourRenderer.Render(content) + if err != nil { + // Fallback to plain content if rendering fails + m.issueContent = content + } else { + m.issueContent = rendered + } + } else { + m.issueContent = content + } + + m.detailViewport.SetContent(m.issueContent) + m.workflowState = IssueDetail + } + } + case "j", "down": + currentChoices := m.getCurrentChoices() + if m.cursor < len(currentChoices)-1 { + m.cursor++ + m.adjustViewForCursor() + } + case "k", "up": + if m.cursor > 0 { + m.cursor-- + m.adjustViewForCursor() + } + case "pgdown", "ctrl+f": + // Page down - move cursor by view height + currentChoices := m.getCurrentChoices() + newCursor := m.cursor + m.viewHeight + if newCursor >= len(currentChoices) { + newCursor = len(currentChoices) - 1 + } + m.cursor = newCursor + m.adjustViewForCursor() + case "pgup", "ctrl+b": + // Page up - move cursor by view height + newCursor := m.cursor - m.viewHeight + if newCursor < 0 { + newCursor = 0 + } + m.cursor = newCursor + m.adjustViewForCursor() + case "home", "ctrl+a": + // Go to first issue + m.cursor = 0 + m.adjustViewForCursor() + case "end", "ctrl+e": + // Go to last issue + currentChoices := m.getCurrentChoices() + m.cursor = len(currentChoices) - 1 + m.adjustViewForCursor() + case "enter", "x", " ": + currentChoices := m.getCurrentChoices() + if m.cursor < len(currentChoices) { + originalIndex := m.getOriginalIndex(m.cursor) + if _, exists := m.selected[originalIndex]; exists { + delete(m.selected, originalIndex) + } else { + m.selected[originalIndex] = struct{}{} + } + m.selectedCount = len(m.selected) + } + case "s": + // Toggle selection of related issues (parent/sub-tasks) + currentChoices := m.getCurrentChoices() + if m.cursor < len(currentChoices) { + issue := currentChoices[m.cursor] + // Load from cache or fetch + related, ok := m.relatedCache[issue.Number] + if !ok { + if rel, err := ghapi.GetRelatedIssueNumbers(issue.Number); err == nil { + related = rel + m.relatedCache[issue.Number] = related + } + } + related = append(related, issue.Number) + logger.Debugf("Related issues for #%d: %v", issue.Number, related) + if len(related) > 0 { + // Determine if all related currently selected + allSelected := true + for _, rn := range related { + // find index for issue number in m.choices + for idx, iss := range m.choices { + if iss.Number == rn { + if _, ok := m.selected[idx]; !ok { + allSelected = false + } + break + } + } + } + // Toggle accordingly + for _, rn := range related { + for idx, iss := range m.choices { + if iss.Number == rn { + if allSelected { + delete(m.selected, idx) + } else { + m.selected[idx] = struct{}{} + } + break + } + } + } + m.selectedCount = len(m.selected) + } + } + case "l": + // Select all visible (filtered) issues + m.selected = make(map[int]struct{}) + currentChoices := m.getCurrentChoices() + for i := range currentChoices { + orig := m.getOriginalIndex(i) + m.selected[orig] = struct{}{} + } + m.selectedCount = len(m.selected) + case "h": + // Clear all selections + m.selected = make(map[int]struct{}) + m.selectedCount = 0 + case "q", "ctrl+c": + return m, tea.Quit + } + case IssueDetail: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "esc": + m.workflowState = NormalMode + case "j", "down": + m.detailViewport.LineDown(1) + case "k", "up": + m.detailViewport.LineUp(1) + case "pgdown", "ctrl+f": + m.detailViewport.HalfViewDown() + case "pgup", "ctrl+b": + m.detailViewport.HalfViewUp() + case "home", "ctrl+a": + m.detailViewport.GotoTop() + case "end", "ctrl+e": + m.detailViewport.GotoBottom() + } + case FilterInput: + switch msg.String() { + case "esc": + m.workflowState = NormalMode + m.filterInput = "" + m.applyFilter() + m.cursor = 0 + m.adjustViewForCursor() + case "enter": + m.workflowState = NormalMode + m.adjustViewForCursor() + case "backspace": + if len(m.filterInput) > 0 { + m.filterInput = m.filterInput[:len(m.filterInput)-1] + m.applyFilter() + m.cursor = 0 + m.adjustViewForCursor() + } + case "q", "ctrl+c": + return m, tea.Quit + default: + // Add character to filter + if len(msg.String()) == 1 { + m.filterInput += msg.String() + m.applyFilter() + m.cursor = 0 + m.adjustViewForCursor() + } + } + case WorkflowSelection: + switch msg.String() { + case "j", "down": + if m.workflowCursor < len(WorkflowTypeValues)-1 { + m.workflowCursor++ + } + case "k", "up": + if m.workflowCursor > 0 { + m.workflowCursor-- + } + case "enter": + m.workflowType = WorkflowType(m.workflowCursor) + switch m.workflowType { + // newworkflow Add to switch to support + case BulkAddLabel, BulkRemoveLabel: + m.workflowState = LabelInput + m.labelInput = "" + case BulkDemoSummary: + return m, m.executeWorkflow() + case BulkSprintKickoff, BulkKickOutOfSprint: + if m.projectID != 0 { + // Use the provided project ID + return m, m.executeWorkflow() + } else { + m.workflowState = ProjectInput + m.projectInput = "" + } + case BulkMilestoneClose: + return m, m.executeWorkflow() + } + case "esc": + m.workflowState = NormalMode + m.errorMessage = "" + } + case LabelInput: + switch msg.String() { + case "enter": + if m.labelInput != "" { + return m, m.executeWorkflow() + } + case "esc": + m.workflowState = NormalMode + m.errorMessage = "" + case "backspace": + if len(m.labelInput) > 0 { + m.labelInput = m.labelInput[:len(m.labelInput)-1] + } + default: + // Add character to input + if len(msg.String()) == 1 { + m.labelInput += msg.String() + } + } + case ProjectInput: + switch msg.String() { + case "enter": + if m.projectInput != "" { + return m, m.executeWorkflow() + } + case "esc": + m.workflowState = NormalMode + m.errorMessage = "" + case "backspace": + if len(m.projectInput) > 0 { + m.projectInput = m.projectInput[:len(m.projectInput)-1] + } + default: + // Add character to input + if len(msg.String()) == 1 { + m.projectInput += msg.String() + } + } + } + default: + // Unknown state, just return + return m, nil + } + return m, nil +} diff --git a/tools/github-manage/pkg/tui/state_change.go b/tools/github-manage/pkg/tui/state_change.go new file mode 100644 index 0000000000..72768897f3 --- /dev/null +++ b/tools/github-manage/pkg/tui/state_change.go @@ -0,0 +1,200 @@ +package tui + +import ( + "fmt" + "time" + + "fleetdm/gm/pkg/ghapi" + + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +func (m *model) HandleStateChange(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case issuesLoadedMsg: + m.choices = msg.issues + m.totalCount = len(m.choices) + m.totalAvailable = msg.totalAvailable + m.rawFetchedCount = msg.rawFetched + m.workflowState = NormalMode + m.applyFilter() // Initialize filter state + m.adjustViewForCursor() // Ensure view is properly initialized + return m, nil + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + case progress.FrameMsg: + progressModel, cmd := m.overallProgress.Update(msg) + m.overallProgress = progressModel.(progress.Model) + cmds = append(cmds, cmd) + case tea.WindowSizeMsg: + // Update viewport size when window is resized + m.detailViewport.Width = msg.Width - 4 + m.detailViewport.Height = msg.Height - 6 + case processTaskMsg: + // This case is no longer used since we've moved to AsyncManager + // Ignore these messages + return m, nil + case startAsyncWorkflowMsg: + if m.workflowState == WorkflowRunning { + // Store the actions and start AsyncManager + m.currentActions = msg.actions + + // Create status channel + m.statusChan = make(chan ghapi.Status) + + // Start AsyncManager in a goroutine + go ghapi.AsyncManager(msg.actions, m.statusChan) + + // Return command to listen for status updates + return m, m.listenForAsyncStatus() + } + case actionStatusMsg: + if m.workflowState == WorkflowRunning && msg.index < len(m.tasks) { + // Update the task based on the action status + if msg.state == "success" { + m.tasks[msg.index].Status = TaskSuccess + m.tasks[msg.index].Progress = 1.0 + } else if msg.state == "error" { + m.tasks[msg.index].Status = TaskError + m.tasks[msg.index].Progress = 0.0 + } + m.currentTask = msg.index + + // Update overall progress + completedTasks := 0 + for _, task := range m.tasks { + if task.Status == TaskSuccess { + completedTasks++ + } + } + overallPercent := float64(completedTasks) / float64(len(m.tasks)) + cmds = append(cmds, m.overallProgress.SetPercent(overallPercent)) + + // Check if we have an error + if msg.state == "error" { + m.workflowState = WorkflowComplete + m.errorMessage = fmt.Sprintf("Task %d failed", msg.index) + } else if completedTasks == len(m.tasks) { + // All tasks completed successfully + m.workflowState = WorkflowComplete + } + // Note: Sequential processing is now handled by AsyncManager, + // so we don't trigger next action processing here + } + case asyncStatusMsg: + if msg.status.State == "done" { + // AsyncManager is finished, all actions completed + m.workflowState = WorkflowComplete + m.statusChan = nil + return m, nil + } + + if m.workflowState == WorkflowRunning && msg.status.Index < len(m.tasks) { + // Update the task based on the async status + if msg.status.State == "success" { + m.tasks[msg.status.Index].Status = TaskSuccess + m.tasks[msg.status.Index].Progress = 1.0 + } else if msg.status.State == "error" { + m.tasks[msg.status.Index].Status = TaskError + m.tasks[msg.status.Index].Progress = 0.0 + } + m.currentTask = msg.status.Index + 1 + + // Update overall progress + completedTasks := 0 + for _, task := range m.tasks { + if task.Status == TaskSuccess { + completedTasks++ + } + } + overallPercent := float64(completedTasks) / float64(len(m.tasks)) + cmds = append(cmds, m.overallProgress.SetPercent(overallPercent)) + + // Check if we have an error + if msg.status.State == "error" { + m.workflowState = WorkflowComplete + m.errorMessage = fmt.Sprintf("Task %d failed", msg.status.Index) + // Channel will be closed by AsyncManager + m.statusChan = nil + } else if completedTasks == len(m.tasks) { + // All tasks completed successfully + m.workflowState = WorkflowComplete + // Channel will be closed by AsyncManager + m.statusChan = nil + } else { + // Continue listening for more status updates + cmds = append(cmds, m.listenForAsyncStatus()) + } + } + case taskUpdateMsg: + if msg.taskID < len(m.tasks) { + m.tasks[msg.taskID].Progress = msg.progress + m.tasks[msg.taskID].Status = msg.status + if msg.err != nil { + m.tasks[msg.taskID].Error = msg.err + } + m.currentTask = msg.taskID + + // If this is an error, mark workflow as complete + if msg.status == TaskError { + m.workflowState = WorkflowComplete + m.errorMessage = fmt.Sprintf("Task failed: %v", msg.err) + cmds = append(cmds, m.overallProgress.SetPercent(1.0)) + } else { + // Update overall progress + totalProgress := float64(0) + completedTasks := 0 + for _, task := range m.tasks { + totalProgress += task.Progress + if task.Status == TaskSuccess { + completedTasks++ + } + } + overallPercent := totalProgress / float64(len(m.tasks)) + cmds = append(cmds, m.overallProgress.SetPercent(overallPercent)) + + // Check if all tasks are completed successfully + if completedTasks == len(m.tasks) { + m.workflowState = WorkflowComplete + // Will show success summary in the view + } else if msg.status == TaskSuccess && (m.workflowType == BulkAddLabel || m.workflowType == BulkRemoveLabel) { + // For bulk label operations, trigger the next task with a delay to ensure serialization + nextTaskID := msg.taskID + 1 + if nextTaskID < len(m.tasks) { + cmds = append(cmds, func() tea.Msg { + // Add a small delay before processing the next task + time.Sleep(50 * time.Millisecond) + return processTaskMsg{taskID: nextTaskID} + }) + } + } + } + } + case workflowExitMsg: + m.exitMessage = msg.message + return m, tea.Quit + case workflowCompleteMsg: + m.workflowState = WorkflowComplete + if !msg.success { + m.errorMessage = msg.message + } + // Set overall progress to 100% + cmds = append(cmds, m.overallProgress.SetPercent(1.0)) + case workflowResultMsg: + // Legacy handler - convert to new system + if msg.err != nil { + m.workflowState = WorkflowComplete + m.errorMessage = fmt.Sprintf("Error: %v", msg.err) + } else { + m.workflowState = WorkflowComplete + } + cmds = append(cmds, m.overallProgress.SetPercent(1.0)) + } + + return m, tea.Batch(cmds...) +} diff --git a/tools/github-manage/pkg/tui/table.go b/tools/github-manage/pkg/tui/table.go new file mode 100644 index 0000000000..73d0e2c633 --- /dev/null +++ b/tools/github-manage/pkg/tui/table.go @@ -0,0 +1,78 @@ +package tui + +import ( + "fmt" + "strings" + + "fleetdm/gm/pkg/ghapi" +) + +func truncTitle(title string) string { + if len(title) > 50 { + return title[:47] + "..." + } + return title +} + +// prioritize labels 'story', 'bug', ':release', ':product', '#g-mdm', '#g-orchestration', +// '#g-software' +// show a maximum of 30 characters, if longer, truncate and add '...+n' where n is the number of additional labels +func truncLables(labels []ghapi.Label) string { + if len(labels) == 0 { + return fmt.Sprintf("%-35s", "- No Labels -") + } + priorityLabels := map[string]bool{"story": true, "bug": true, ":release": true, ":product": true, "#g-mdm": true, "#g-orchestration": true, "#g-software": true} + var labelNames []string + for _, label := range labels { + labelNames = append(labelNames, label.Name) + } + displayLabels := []string{} + secondaryLabels := []string{} + for _, label := range labelNames { + if priorityLabels[label] { + displayLabels = append(displayLabels, label) + } else { + secondaryLabels = append(secondaryLabels, label) + } + } + if len(secondaryLabels) > 0 { + displayLabels = append(displayLabels, secondaryLabels...) + } + countDisplay := 0 + sumCharacters := 0 + for _, label := range displayLabels { + if (sumCharacters + len(label)) > 30 { + break + } + countDisplay += 1 + sumCharacters += len(label) + 2 + } + extraLabels := 0 + if countDisplay < len(displayLabels) { + extraLabels = len(displayLabels) - countDisplay + } + extraString := "" + if extraLabels > 0 { + extraString = fmt.Sprintf("...+%d", extraLabels) + } + return fmt.Sprintf("%-35s", fmt.Sprintf("%s%s", strings.Join(displayLabels[:countDisplay], ", "), extraString)) +} + +func truncEstimate(estimate int) string { + estString := fmt.Sprintf("%d", estimate) + if estimate == 0 { + estString = "-" + } + return fmt.Sprintf("%-9s", estString) +} + +func truncType(typename string) string { + if typename == "" { + typename = "-" + } + // Return the typename with spaces filling out to 10 characters + if len(typename) < 10 { + return fmt.Sprintf("%-10s", typename) + } + return strings.TrimSpace(typename[:10]) +} diff --git a/tools/github-manage/pkg/tui/ui.go b/tools/github-manage/pkg/tui/ui.go new file mode 100644 index 0000000000..7b1f93c55f --- /dev/null +++ b/tools/github-manage/pkg/tui/ui.go @@ -0,0 +1,759 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + + "fleetdm/gm/pkg/ghapi" + + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" +) + +type CommandType int + +const ( + IssuesCommand CommandType = iota + ProjectCommand + EstimatedCommand + SprintCommand +) + +type WorkflowState int + +const ( + Loading WorkflowState = iota + NormalMode + IssueDetail + FilterInput + WorkflowSelection + LabelInput + ProjectInput + WorkflowRunning + WorkflowComplete +) + +// Adding new workflow needs type, values +// And support in switch cases +// Search code for newworkflow +type WorkflowType int + +const ( + BulkAddLabel WorkflowType = iota + BulkRemoveLabel + BulkSprintKickoff + BulkMilestoneClose + BulkKickOutOfSprint + BulkDemoSummary +) + +var WorkflowTypeValues = []string{ + "Bulk Add Label", + "Bulk Remove Label", + "Bulk Sprint Kickoff", + "Bulk Milestone Close", + "Bulk Kick Out Of Sprint", + "Bulk Demo Summary", +} + +type TaskStatus int + +const ( + TaskPending TaskStatus = iota + TaskInProgress + TaskSuccess + TaskError +) + +type WorkflowTask struct { + ID int + Description string + Status TaskStatus + Progress float64 + Error error +} + +var ( + statusStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#7D56F4")). + Padding(0, 1) + + successStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#04B575")). + Padding(0, 1) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#FF0000")). + Padding(0, 1) + + pendingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#888888")). + Padding(0, 1) +) + +type processTaskMsg struct { + taskID int +} + +type actionStatusMsg struct { + index int + state string +} +type startAsyncWorkflowMsg struct { + actions []ghapi.Action +} + +type asyncStatusMsg struct { + status ghapi.Status +} + +type model struct { + choices []ghapi.Issue + cursor int + selected map[int]struct{} + relatedCache map[int][]int // issue number -> related issue numbers + spinner spinner.Model + totalCount int + totalAvailable int // reported total items in project (may exceed totalCount if limited) + rawFetchedCount int // number of items actually fetched before mode-specific filtering + selectedCount int + // Scrolling support + viewOffset int // Offset for scrolling view + viewHeight int // Number of visible lines for issues + // Filtering support + filterInput string + filteredChoices []ghapi.Issue + originalIndices []int // Maps filtered index to original index + // Issue detail view + detailViewport viewport.Model + glamourRenderer *glamour.TermRenderer + issueContent string + // Command-specific parameters + commandType CommandType + projectID int + limit int + search string + // Workflow state + workflowState WorkflowState + workflowType WorkflowType + workflowCursor int + labelInput string + projectInput string + errorMessage string + // Progress tracking + tasks []WorkflowTask + currentTask int + overallProgress progress.Model + githubOpInProgress bool // Ensure only one GitHub operation at a time + currentActions []ghapi.Action // Store current actions being processed + statusChan chan ghapi.Status // Channel for receiving status updates from AsyncManager + exitMessage string +} + +type issuesLoadedMsg struct { + issues []ghapi.Issue + totalAvailable int + rawFetched int +} + +type workflowResultMsg struct { + message string + err error +} + +type taskUpdateMsg struct { + taskID int + progress float64 + status TaskStatus + err error +} + +type workflowCompleteMsg struct { + success bool + message string +} + +type workflowExitMsg struct { + success bool + message string +} + +func RunTUI(commandType CommandType, projectID int, limit int, search string) { + var mm model + switch commandType { + case ProjectCommand: + mm = initializeModelForProject(projectID, limit) + case EstimatedCommand: + mm = initializeModelForEstimated(projectID, limit) + case SprintCommand: + mm = initializeModelForSprint(projectID, limit) + case IssuesCommand: + mm = initializeModelForIssues(search) + default: + // error and exit + fmt.Println("Unsupported command type for TUI") + return + } + p := tea.NewProgram(&mm) + if _, err := p.Run(); err != nil { + fmt.Printf("Error running Bubble Tea program: %v\n", err) + } + if mm.exitMessage != "" { + fmt.Println(mm.exitMessage) + } +} + +// base model w/ global defaults +func initializeModel() model { + s := spinner.New() + s.Spinner = spinner.Moon + s.Style = s.Style.Foreground(spinner.New().Style.GetForeground()) + + p := progress.New(progress.WithDefaultGradient()) + p.Width = 40 + + // Initialize glamour renderer + renderer, _ := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(80), + ) + + // Initialize viewport for issue details + vp := viewport.New(80, 20) + vp.Style = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Padding(1) + + return model{ + choices: nil, + cursor: 0, + selected: make(map[int]struct{}), + relatedCache: make(map[int][]int), + spinner: s, + totalCount: 0, + totalAvailable: 0, + rawFetchedCount: 0, + selectedCount: 0, + viewOffset: 0, + viewHeight: 15, // Default height, will be adjusted based on screen size + filterInput: "", + filteredChoices: nil, + originalIndices: nil, + detailViewport: vp, + glamourRenderer: renderer, + issueContent: "", + commandType: IssuesCommand, + workflowState: Loading, + workflowCursor: 0, + labelInput: "", + projectInput: "", + errorMessage: "", + tasks: []WorkflowTask{}, + currentTask: -1, + overallProgress: p, + githubOpInProgress: false, + currentActions: []ghapi.Action{}, + } +} + +// Each view should have it's own initialize model to +// add details to help the queries newview +func initializeModelForIssues(search string) model { + m := initializeModel() + m.search = search + return m +} + +func initializeModelForProject(projectID, limit int) model { + m := initializeModel() + m.commandType = ProjectCommand + m.projectID = projectID + m.limit = limit + return m +} + +func initializeModelForEstimated(projectID, limit int) model { + m := initializeModel() + m.commandType = EstimatedCommand + m.projectID = projectID + m.limit = limit + return m +} + +func initializeModelForSprint(projectID, limit int) model { + m := initializeModel() + m.commandType = SprintCommand + m.projectID = projectID + m.limit = limit + return m +} + +// newview Add a corresponding issue fetcher +func fetchIssues(search string) tea.Cmd { + return func() tea.Msg { + issues, err := ghapi.GetIssues(search) + if err != nil { + return err + } + return issuesLoadedMsg{issues: issues, totalAvailable: len(issues), rawFetched: len(issues)} + } +} + +func fetchProjectItems(projectID, limit int) tea.Cmd { + return func() tea.Msg { + items, total, err := ghapi.GetProjectItemsWithTotal(projectID, limit) + if err != nil { + return err + } + issues := ghapi.ConvertItemsToIssues(items) + return issuesLoadedMsg{issues: issues, totalAvailable: total, rawFetched: limit} + } +} + +func fetchEstimatedItems(projectID, limit int) tea.Cmd { + return func() tea.Msg { + items, total, err := ghapi.GetEstimatedTicketsForProjectWithTotal(projectID, limit) + if err != nil { + return err + } + issues := ghapi.ConvertItemsToIssues(items) + // totalAvailable reflects total items in drafting project; rawFetched is the fetch limit + return issuesLoadedMsg{issues: issues, totalAvailable: total, rawFetched: limit} + } +} + +func fetchSprintItems(projectID, limit int) tea.Cmd { + return func() tea.Msg { + items, total, err := ghapi.GetCurrentSprintItemsWithTotal(projectID, limit) + if err != nil { + return err + } + issues := ghapi.ConvertItemsToIssues(items) + return issuesLoadedMsg{issues: issues, totalAvailable: total, rawFetched: limit} + } +} + +// newview add command type / fetcher to switch +func (m model) Init() tea.Cmd { + var fetchCmd tea.Cmd + switch m.commandType { + case IssuesCommand: + fetchCmd = fetchIssues(m.search) + case ProjectCommand: + fetchCmd = fetchProjectItems(m.projectID, m.limit) + case EstimatedCommand: + fetchCmd = fetchEstimatedItems(m.projectID, m.limit) + case SprintCommand: + fetchCmd = fetchSprintItems(m.projectID, m.limit) + default: + fetchCmd = fetchIssues("") + } + return tea.Batch(fetchCmd, m.spinner.Tick) +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.HandleHotkeys(msg) + default: + return m.HandleStateChange(msg) + } +} + +func (m *model) adjustViewForCursor() { + currentChoices := m.getCurrentChoices() + + // Ensure cursor is within bounds + if len(currentChoices) == 0 { + m.cursor = 0 + m.viewOffset = 0 + return + } + + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= len(currentChoices) { + m.cursor = len(currentChoices) - 1 + } + + // Adjust view offset to keep cursor visible + if m.cursor < m.viewOffset { + // Cursor is above visible area, scroll up + m.viewOffset = m.cursor + } else if m.cursor >= m.viewOffset+m.viewHeight { + // Cursor is below visible area, scroll down + m.viewOffset = m.cursor - m.viewHeight + 1 + } + + // Ensure view offset doesn't go negative or beyond available items + if m.viewOffset < 0 { + m.viewOffset = 0 + } + maxOffset := len(currentChoices) - m.viewHeight + if maxOffset < 0 { + maxOffset = 0 + } + if m.viewOffset > maxOffset { + m.viewOffset = maxOffset + } +} + +func (m model) View() string { + s := "" + switch m.workflowState { + case Loading: + s = m.RenderLoading() + + case WorkflowRunning: + s = m.RenderWorkflowRunning() + + case WorkflowComplete: + s = m.RenderWorkflowComplete() + + case NormalMode: + s = m.RenderIssueTable() + + case IssueDetail: + s = m.RenderIssueDetail() + + case FilterInput: + s = m.RenderFilterInput() + + case WorkflowSelection: + s = m.RenderWorkflowSelection() + + case LabelInput: + s = m.RenderLabelInput() + + case ProjectInput: + s = m.RenderProjectInput() + } + + if m.errorMessage != "" && m.workflowState != WorkflowComplete { + s += fmt.Sprintf("\nError: %s\n", m.errorMessage) + } + + return s +} + +func (m *model) executeWorkflow() tea.Cmd { + // Initialize workflow tasks + m.workflowState = WorkflowRunning + m.currentTask = 0 + m.tasks = []WorkflowTask{} + + // Collect selected issues + var selectedIssues []ghapi.Issue + for i := range m.selected { + if i < len(m.choices) { + selectedIssues = append(selectedIssues, m.choices[i]) + } + } + + if len(selectedIssues) == 0 { + return func() tea.Msg { + return workflowCompleteMsg{ + success: false, + message: "No issues selected", + } + } + } + + // Create actions and tasks based on workflow type + var actions []ghapi.Action + switch m.workflowType { + // newworkflow add workflow steps here (actions / tasks) + case BulkDemoSummary: + // Build summary markdown grouped by label category and assignee + features := map[string][]ghapi.Issue{} + bugs := map[string][]ghapi.Issue{} + // if status lower ends with 'ready' + assigneeOrUnassigned := func(issue ghapi.Issue) string { + if len(issue.Assignees) > 0 { + user, err := ghapi.GetUserName(issue.Assignees[0].Login) + if err == nil && user.Name != "" { + return user.Name + } + } + return "unassigned" + } + for _, issue := range selectedIssues { + if issue.Status == "" || strings.HasSuffix(strings.ToLower(issue.Status), "ready") { + continue + // Do something + } + isFeature := false + isBug := false + for _, l := range issue.Labels { + if l.Name == "story" { + isFeature = true + } + if l.Name == "bug" { + isBug = true + } + if l.Name == "~unreleased bug" { + isBug = false + } + } + assignee := assigneeOrUnassigned(issue) + if isFeature { + features[assignee] = append(features[assignee], issue) + } + if isBug { + bugs[assignee] = append(bugs[assignee], issue) + } + } + // Generate markdown content + var builder strings.Builder + builder.WriteString("## Features Completed\n\n") + if len(features) == 0 { + builder.WriteString("_None_\n\n") + } + var featureAssignees []string + for a := range features { + featureAssignees = append(featureAssignees, a) + } + sort.Strings(featureAssignees) + for _, a := range featureAssignees { + builder.WriteString(fmt.Sprintf("%s\n", a)) + for _, issue := range features[a] { + builder.WriteString(fmt.Sprintf("[#%d](https://github.com/fleetdm/fleet/issues/%d)%s\n", issue.Number, issue.Number, issue.Title)) + } + builder.WriteString("\n") + } + builder.WriteString("## Bugs Completed\n\n") + if len(bugs) == 0 { + builder.WriteString("_None_\n\n") + } + var bugAssignees []string + for a := range bugs { + bugAssignees = append(bugAssignees, a) + } + sort.Strings(bugAssignees) + for _, a := range bugAssignees { + builder.WriteString(fmt.Sprintf("%s\n", a)) + for _, issue := range bugs[a] { + builder.WriteString(fmt.Sprintf("[#%d](https://github.com/fleetdm/fleet/issues/%d)%s\n", issue.Number, issue.Number, issue.Title)) + } + builder.WriteString("\n") + } + return func() tea.Msg { return workflowExitMsg{success: true, message: builder.String()} } + case BulkAddLabel: + actions = ghapi.CreateBulkAddLableAction(selectedIssues, m.labelInput) + // Create individual tasks for each issue + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: i, + Description: fmt.Sprintf("Adding label '%s' to issue #%d", m.labelInput, issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + case BulkRemoveLabel: + actions = ghapi.CreateBulkRemoveLabelAction(selectedIssues, m.labelInput) + // Create individual tasks for each issue + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: i, + Description: fmt.Sprintf("Removing label '%s' from issue #%d", m.labelInput, issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + case BulkSprintKickoff: + projectID := m.projectID + if projectID == 0 && m.projectInput != "" { + // Try to resolve project ID + resolvedID, err := ghapi.ResolveProjectID(m.projectInput) + if err != nil { + return func() tea.Msg { + return workflowCompleteMsg{ + success: false, + message: fmt.Sprintf("Failed to resolve project ID: %v", err), + } + } + } + projectID = resolvedID + } + + actions = ghapi.CreateBulkSprintKickoffActions(selectedIssues, ghapi.Aliases["draft"], projectID) + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: i, + Description: fmt.Sprintf("Adding #%d issue to project %d", issue.Number, projectID), + Status: TaskPending, + Progress: 0.0, + }) + } + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: len(selectedIssues) + i, + Description: fmt.Sprintf("Adding ':release' label to #%d issue", issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: (2 * len(selectedIssues)) + i, + Description: fmt.Sprintf("Syncing estimate fields for #%d issue", issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: (3 * len(selectedIssues)) + i, + Description: fmt.Sprintf("Setting current sprint for #%d issue in project %d", issue.Number, projectID), + Status: TaskPending, + Progress: 0.0, + }) + } + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: (4 * len(selectedIssues)) + i, + Description: fmt.Sprintf("Removing ':product' label from #%d issue", issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: (5 * len(selectedIssues)) + i, + Description: fmt.Sprintf("Removing #%d issue from drafting project", issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + case BulkMilestoneClose: + actions = ghapi.CreateBulkMilestoneCloseActions(selectedIssues) + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: len(selectedIssues) + i, + Description: fmt.Sprintf("Adding #%d issue to drafting project", issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: len(selectedIssues) + i, + Description: fmt.Sprintf("Adding ':product' label to #%d issue", issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: len(selectedIssues) + i, + Description: fmt.Sprintf("Setting status to 'confirm and celebrate' for #%d issue", issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: len(selectedIssues) + i, + Description: fmt.Sprintf("Removing ':release' label from #%d issue", issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + case BulkKickOutOfSprint: + projectID := m.projectID + if projectID == 0 && m.projectInput != "" { + // Try to resolve project ID + resolvedID, err := ghapi.ResolveProjectID(m.projectInput) + if err != nil { + return func() tea.Msg { + return workflowCompleteMsg{ + success: false, + message: fmt.Sprintf("Failed to resolve project ID: %v", err), + } + } + } + projectID = resolvedID + } + + actions = ghapi.CreateBulkKickOutOfSprintActions(selectedIssues, projectID) + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: i, + Description: fmt.Sprintf("Adding #%d issue to drafting project", issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: len(selectedIssues) + i, + Description: fmt.Sprintf("Setting status to 'estimated' for #%d issue", issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: (2 * len(selectedIssues)) + i, + Description: fmt.Sprintf("Syncing estimate from project %d for #%d issue", projectID, issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: (3 * len(selectedIssues)) + i, + Description: fmt.Sprintf("Adding ':product' label to #%d issue", issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: (4 * len(selectedIssues)) + i, + Description: fmt.Sprintf("Removing ':release' label from #%d issue", issue.Number), + Status: TaskPending, + Progress: 0.0, + }) + } + for i, issue := range selectedIssues { + m.tasks = append(m.tasks, WorkflowTask{ + ID: (4 * len(selectedIssues)) + i, + Description: fmt.Sprintf("Removing #%d issue from project %d", issue.Number, projectID), + Status: TaskPending, + Progress: 0.0, + }) + } + } + + // For all workflows, start async workflow + return m.executeAsyncWorkflow(actions) +} + +func (m *model) listenForAsyncStatus() tea.Cmd { + return func() tea.Msg { + status, ok := <-m.statusChan + if !ok { + // Channel is closed, AsyncManager is done + return asyncStatusMsg{status: ghapi.Status{Index: -1, State: "done"}} + } + return asyncStatusMsg{status: status} + } +} + +func (m *model) executeAsyncWorkflow(actions []ghapi.Action) tea.Cmd { + return func() tea.Msg { + return startAsyncWorkflowMsg{actions: actions} + } +} diff --git a/tools/github-manage/pkg/tui/view.go b/tools/github-manage/pkg/tui/view.go new file mode 100644 index 0000000000..6d79999d70 --- /dev/null +++ b/tools/github-manage/pkg/tui/view.go @@ -0,0 +1,393 @@ +package tui + +import ( + "fmt" + "strings" + + "fleetdm/gm/pkg/ghapi" +) + +func (m *model) generateIssueContent(issue ghapi.Issue) string { + var content strings.Builder + + // Title + content.WriteString(fmt.Sprintf("# %s\n\n", issue.Title)) + + // Metadata section + content.WriteString("## Issue Details\n\n") + content.WriteString(fmt.Sprintf("**Number:** #%d\n\n", issue.Number)) + content.WriteString(fmt.Sprintf("**Type:** %s\n\n", issue.Typename)) + + // Estimate + if issue.Estimate > 0 { + content.WriteString(fmt.Sprintf("**Estimate:** %d\n\n", issue.Estimate)) + } else { + content.WriteString("**Estimate:** Not set\n\n") + } + + // Labels + if len(issue.Labels) > 0 { + content.WriteString("**Labels:** ") + var labelNames []string + for _, label := range issue.Labels { + labelNames = append(labelNames, label.Name) + } + content.WriteString(strings.Join(labelNames, ", ")) + content.WriteString("\n\n") + } else { + content.WriteString("**Labels:** None\n\n") + } + + // State and metadata + content.WriteString(fmt.Sprintf("**State:** %s\n\n", issue.State)) + content.WriteString(fmt.Sprintf("**Created:** %s\n\n", issue.CreatedAt)) + content.WriteString(fmt.Sprintf("**Updated:** %s\n\n", issue.UpdatedAt)) + + // Author + content.WriteString(fmt.Sprintf("**Author:** %s\n\n", issue.Author.Login)) + + // Assignees + if len(issue.Assignees) > 0 { + content.WriteString("**Assignees:** ") + var assigneeNames []string + for _, assignee := range issue.Assignees { + assigneeNames = append(assigneeNames, assignee.Login) + } + content.WriteString(strings.Join(assigneeNames, ", ")) + content.WriteString("\n\n") + } + + // Milestone + if issue.Milestone != nil { + content.WriteString(fmt.Sprintf("**Milestone:** %s\n\n", issue.Milestone.Title)) + } + + // Description + content.WriteString("## Description\n\n") + if issue.Body != "" { + content.WriteString(issue.Body) + } else { + content.WriteString("*No description provided.*") + } + content.WriteString("\n\n") + + return content.String() +} + +func (m model) RenderLoading() string { + var loadingMessage string + switch m.commandType { + // newview add if non default message is preferred when loading + case IssuesCommand: + loadingMessage = "Fetching Issues..." + case ProjectCommand: + loadingMessage = fmt.Sprintf("Fetching Project Items (ID: %d)...", m.projectID) + case EstimatedCommand: + loadingMessage = fmt.Sprintf("Fetching Estimated Tickets (Project: %d)...", m.projectID) + default: + loadingMessage = "Fetching Issues..." + } + return fmt.Sprintf("\n%s %s\n\n", m.spinner.View(), loadingMessage) +} + +func (m model) RenderWorkflowRunning() string { + s := "\nπŸš€ Workflow Execution in Progress\n\n" + + // Show overall progress + s += fmt.Sprintf("Overall Progress: %s\n\n", m.overallProgress.View()) + + // Determine window of tasks to display (show most recent 10, auto-scroll) + totalTasks := len(m.tasks) + lastFinished := -1 + for i := range m.tasks { + if m.tasks[i].Status == TaskSuccess || m.tasks[i].Status == TaskError { + lastFinished = i + } + } + // Prefer to keep the currently running task in view + lastIdx := lastFinished + if m.currentTask > lastIdx { + lastIdx = m.currentTask + } + if lastIdx < 0 { + lastIdx = 0 + } + windowSize := 10 + start := lastIdx - windowSize + 1 + if start < 0 { + start = 0 + } + end := start + windowSize + if end > totalTasks { + end = totalTasks + } + + // Show individual task progress (windowed) + if start > 0 { + s += fmt.Sprintf("... %d earlier task(s) above ...\n", start) + } + for i := start; i < end; i++ { + task := m.tasks[i] + var statusIcon string + var statusText string + + switch task.Status { + case TaskPending: + statusIcon = "⏳" + statusText = pendingStyle.Render("PENDING") + case TaskInProgress: + statusIcon = "πŸ”„" + statusText = statusStyle.Render("RUNNING") + case TaskSuccess: + statusIcon = "βœ…" + statusText = successStyle.Render("SUCCESS") + case TaskError: + statusIcon = "❌" + statusText = errorStyle.Render("ERROR") + } + + // Override icon for current task to show it's active + if i == m.currentTask && task.Status != TaskSuccess && task.Status != TaskError { + statusIcon = "πŸƒβ€β™€οΈ" + } + + s += fmt.Sprintf("%s %s %s", statusIcon, statusText, task.Description) + + if task.Error != nil { + s += fmt.Sprintf(" - Error: %v", task.Error) + } + s += "\n" + } + if end < totalTasks { + s += fmt.Sprintf("... %d more task(s) below ...\n", totalTasks-end) + } + + // Add progress counter at the bottom + completedTasks := 0 + for _, task := range m.tasks { + if task.Status == TaskSuccess { + completedTasks++ + } + if task.Status == TaskError { + completedTasks++ + } + } + + progressPercent := 0.0 + if totalTasks > 0 { + progressPercent = float64(completedTasks) / float64(totalTasks) * 100 + } + + s += fmt.Sprintf("\nProgress: %d/%d tasks completed (%.1f%%)", completedTasks, totalTasks, progressPercent) + s += "\n\nPress 'q' to quit" + return s +} + +func (m model) RenderWorkflowComplete() string { + s := "\nπŸŽ‰ Workflow Complete!\n\n" + + // Show final progress (should be 100%) + s += fmt.Sprintf("Final Progress: %s\n\n", m.overallProgress.View()) + + // Show final task statuses + successCount := 0 + errorCount := 0 + for _, task := range m.tasks { + var statusIcon string + var statusText string + + switch task.Status { + case TaskSuccess: + statusIcon = "βœ…" + statusText = successStyle.Render("SUCCESS") + successCount++ + case TaskError: + statusIcon = "❌" + statusText = errorStyle.Render("ERROR") + errorCount++ + default: + statusIcon = "⚠️" + statusText = pendingStyle.Render("PENDING") + } + + s += fmt.Sprintf("%s %s %s", statusIcon, statusText, task.Description) + if task.Error != nil { + s += fmt.Sprintf(" - Error: %v", task.Error) + } + s += "\n" + } + + s += fmt.Sprintf("\nSummary: %d successful, %d failed out of %d tasks\n", successCount, errorCount, len(m.tasks)) + + if m.errorMessage != "" { + s += fmt.Sprintf("\n%s\n", m.errorMessage) + } + + s += "\nPress any key to exit..." + return s +} + +func (m model) RenderIssueTable() string { + s := "" + currentChoices := m.getCurrentChoices() + currentPos := m.cursor + 1 + totalFiltered := len(currentChoices) + + // Compute sum of estimates for currently selected issues + sumSelectedEstimates := 0 + for idx := range m.selected { + if idx >= 0 && idx < len(m.choices) { + sumSelectedEstimates += m.choices[idx].Estimate + } + } + + // Header with filter info + headerText := "" + if m.filterInput != "" { + headerText = fmt.Sprintf("GitHub Issues (%d/%d, Ξ£est sel=%d) - Filtered by: '%s':\n\n", currentPos, totalFiltered, sumSelectedEstimates, m.filterInput) + } else { + headerText = fmt.Sprintf("GitHub Issues (%d/%d, Ξ£est sel=%d):\n\n", currentPos, m.totalCount, sumSelectedEstimates) + } + + warningBanner := "" + if m.totalAvailable > 0 && m.totalAvailable > m.rawFetchedCount { + missing := m.totalAvailable - m.rawFetchedCount + warningBanner = errorStyle.Render(fmt.Sprintf("⚠ %d items not shown (limit=%d, total=%d). Increase --limit to include all issues.", missing, m.rawFetchedCount, m.totalAvailable)) + "\n\n" + } + + s = warningBanner + headerText + fmt.Sprintf(" %-2d/%-2d Number Estimate Type Labels Title\n", + m.selectedCount, m.totalCount) + + // Show indicator if there are issues above the visible area + if m.viewOffset > 0 { + s += fmt.Sprintf(" %s %-6s %s %s %s %s\n", + " ", "...", " ", " ", strings.Repeat(" ", 35), "More issues above") + } + + // Calculate which issues to display + startIdx := m.viewOffset + endIdx := m.viewOffset + m.viewHeight + if endIdx > len(currentChoices) { + endIdx = len(currentChoices) + } + + // Display visible issues + for i := startIdx; i < endIdx; i++ { + issue := currentChoices[i] + originalIndex := m.getOriginalIndex(i) + cursor := " " + if i == m.cursor { + cursor = ">" + } + selected := "" + if _, exists := m.selected[originalIndex]; exists { + selected = "[x] " + } else { + selected = "[ ] " + } + s += fmt.Sprintf("%s %s %s %s %s %s %s\n", + cursor, selected, fmt.Sprintf("%-6d", issue.Number), truncEstimate(issue.Estimate), + truncType(issue.Typename), truncLables(issue.Labels), truncTitle(issue.Title)) + } + + // Show indicator if there are issues below the visible area + if m.viewOffset+m.viewHeight < len(currentChoices) { + s += fmt.Sprintf(" %s %-6s %s %s %s %s\n", + " ", "...", " ", " ", strings.Repeat(" ", 35), "More issues below") + } + + s += "\nNavigation: ↑/↓ or j/k (line), PgUp/PgDn (page), Home/End (top/bottom)\n" + s += "Actions: enter/space/x (select), 'l' select all, 'h' deselect all, 'o' (view details), '/' (filter), 'w' (workflow), 'q' (quit)\n" + return s +} + +func (m model) RenderIssueDetail() string { + s := "" + if len(m.choices) > 0 && m.cursor < len(m.choices) { + issue := m.choices[m.cursor] + s += fmt.Sprintf("Issue #%d Details\n\n", issue.Number) + s += m.detailViewport.View() + s += "\n\nNavigation: ↑/↓ or j/k (line), PgUp/PgDn (page), Home/End (top/bottom)\n" + s += "Actions: 'esc' (back to list), 'q' (quit)\n" + } else { + s = "No issue selected\n" + } + return s +} + +func (m model) RenderFilterInput() string { + currentChoices := m.getCurrentChoices() + totalFiltered := len(currentChoices) + + s := fmt.Sprintf("Filter Issues - %d results:\n", totalFiltered) + s += fmt.Sprintf("Filter: %s_\n\n", m.filterInput) + + // Show a preview of filtered results (top 10) + previewCount := 10 + if totalFiltered < previewCount { + previewCount = totalFiltered + } + + s += "Preview:\n" + for i := 0; i < previewCount; i++ { + issue := currentChoices[i] + originalIndex := m.getOriginalIndex(i) + selected := "" + if _, exists := m.selected[originalIndex]; exists { + selected = "[x] " + } else { + selected = "[ ] " + } + s += fmt.Sprintf(" %s #%-6d %s\n", selected, issue.Number, truncTitle(issue.Title)) + } + + if totalFiltered > previewCount { + s += fmt.Sprintf(" ... and %d more\n", totalFiltered-previewCount) + } + + s += "\nType to filter by number, title, labels, or description\n" + s += "Actions: 'enter' (apply filter), 'esc' (cancel), 'q' (quit)\n" + return s +} + +func (m model) RenderWorkflowSelection() string { + s := "\n--- Workflow Selection ---\n" + workflows := WorkflowTypeValues + for i, workflow := range workflows { + cursor := " " + if i == m.workflowCursor { + cursor = ">" + } + selected := "( )" + if i == m.workflowCursor { + selected = "(*)" + } + s += fmt.Sprintf("%s %s %s\n", cursor, selected, workflow) + } + s += "\nPress 'enter' to select, 'esc' to cancel.\n" + return s +} + +func (m model) RenderLabelInput() string { + workflowName := "Add Label" + if m.workflowType == BulkRemoveLabel { + workflowName = "Remove Label" + } + s := fmt.Sprintf("\n--- %s ---\n", workflowName) + s += fmt.Sprintf("Label: %s_\n", m.labelInput) + s += "Press 'enter' to execute, 'esc' to cancel.\n" + return s +} + +func (m model) RenderProjectInput() string { + workflowTitle := "Sprint Kickoff" + promptText := "Target Project (ID or alias):" + if m.workflowType == BulkKickOutOfSprint { + workflowTitle = "Kick Out Of Sprint" + promptText = "Source Project (ID or alias):" + } + s := fmt.Sprintf("\n--- %s ---\n", workflowTitle) + s += fmt.Sprintf("%s %s_\n", promptText, m.projectInput) + s += "Press 'enter' to execute, 'esc' to cancel.\n" + return s +}