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 MilestoneCommand ) 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 BulkMoveToCurrentSprint ) var WorkflowTypeValues = []string{ "Bulk Add Label", "Bulk Remove Label", "Bulk Sprint Kickoff", "Bulk Milestone Close", "Bulk Kick Out Of Sprint", "Bulk Demo Summary", "Move to current sprint", } 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 // Terminal sizing termWidth int // 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) case MilestoneCommand: mm = initializeModelForMilestone(search, limit) default: // error and exit fmt.Println("Unsupported command type for TUI") return } // Carry over the search string as a generic mode hint (e.g., for sprint: "previous") mm.search = search 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 } func initializeModelForMilestone(title string, limit int) model { m := initializeModel() m.commandType = MilestoneCommand m.limit = limit m.search = title // reuse search to carry milestone title 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} } } func fetchPreviousSprintItems(projectID, limit int) tea.Cmd { return func() tea.Msg { items, total, err := ghapi.GetPreviousSprintItemsWithTotal(projectID, limit) if err != nil { return err } issues := ghapi.ConvertItemsToIssues(items) return issuesLoadedMsg{issues: issues, totalAvailable: total, rawFetched: limit} } } func fetchMilestoneItems(title string, limit int) tea.Cmd { return func() tea.Msg { // Get open-issue count for this milestone to drive warning banner totalCount, cntErr := ghapi.GetMilestoneOpenIssueCount(title) // Fetch up to 'limit' open issues for the milestone issues, exceeded, err := ghapi.GetIssuesByMilestoneLimited(title, limit) if err != nil { return err } // Prefer accurate total from GraphQL; fallback to limit+1 sentinel when cntErr occurs total := totalCount if cntErr != nil { total = len(issues) if exceeded { total = limit + 1 } } 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: // Use the search field as a mode hint; when "previous", fetch previous sprint instead of current if strings.EqualFold(strings.TrimSpace(m.search), "previous") || strings.EqualFold(strings.TrimSpace(m.search), "prev") { fetchCmd = fetchPreviousSprintItems(m.projectID, m.limit) } else { fetchCmd = fetchSprintItems(m.projectID, m.limit) } case MilestoneCommand: fetchCmd = fetchMilestoneItems(m.search, 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 { if user.Name != "" { return user.Name } else { return user.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, }) } case BulkMoveToCurrentSprint: projectID := m.projectID if projectID == 0 && m.projectInput != "" { 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 } if projectID == 0 { return func() tea.Msg { return workflowCompleteMsg{ success: false, message: "Project ID is required for this workflow", } } } actions = ghapi.CreateBulkMoveToCurrentSprintIfNotReadyQA(selectedIssues, projectID) for i, issue := range selectedIssues { // Only tasks for those that will be acted on; description indicates conditional nature desc := fmt.Sprintf("If not 'ready' or 'qa', set current sprint for #%d in project %d", issue.Number, projectID) m.tasks = append(m.tasks, WorkflowTask{ ID: i, Description: desc, 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} } }