fleet/tools/github-manage/pkg/tui/ui.go
Lucas Manuel Rodriguez e3ada60c6a
Fix gm build (#37524)
2025-12-19 09:41:47 -03:00

852 lines
23 KiB
Go

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}
}
}