fleet/tools/github-manage/cmd/gm/ui.go
George Karr 6ebbef874b
adding sum of estimates and fixing workflow progress menu (#32694)
What’s in this PR

1) Smarter default sorting for issues (used by the TUI)

New ghapi.SortIssuesForDisplay helper that orders issues by:

Priority label (P0 → P1 → P2 → none)

Presence of customer-* / prospect-* labels

Type labels (story → bug → ~sub-task → others)

Issue number (descending)
This is applied before filtering so views start in a meaningful order. 
[GitHub](https://github.com/fleetdm/fleet/pull/32694/files)

Implementation lives in tools/github-manage/pkg/ghapi/sort.go.
Comprehensive tests cover all combinations, tie-breakers, and stability.
GitHub
+1

2) Estimates: show the sum for the current selection

The header now displays Σest sel=<sum> for the currently selected
issues, both in filtered and unfiltered views, making quick capacity
checks easier.
[GitHub](https://github.com/fleetdm/fleet/pull/32694/files)

3) Better progress UI for workflows

Task list is now windowed (last ~10 items) with auto-scroll to the
currently running or most recently finished task, plus “earlier/more
tasks” ellipses and a progress counter at the bottom. This keeps the
view focused during long runs.
[GitHub](https://github.com/fleetdm/fleet/pull/32694/files)

4) Project estimates fetch now includes total count

Switched from GetEstimatedTicketsForProject to
GetEstimatedTicketsForProjectWithTotal, so we can show totalAvailable
alongside rawFetched/limit.
[GitHub](https://github.com/fleetdm/fleet/pull/32694/files)

---------

Co-authored-by: Jordan Montgomery <elijah.jordan.montgomery@gmail.com>
2025-09-11 13:47:46 -05:00

1694 lines
46 KiB
Go

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