gkarr gm updates pre sprint (#37446)

This commit is contained in:
George Karr 2025-12-18 14:55:38 -06:00 committed by GitHub
parent b095b72bf1
commit 4003ac22de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 510 additions and 81 deletions

View file

@ -80,6 +80,15 @@ chmod +x gm
# View estimated tickets
./gm estimated mdm --limit 25
# Pre-sprint report for one or more teams
# Single team (alias or project id)
./gm pre-sprint report mdm
# Multiple teams (comma-separated)
./gm pre-sprint report mdm,soft --limit 1000
# CSV format for spreadsheet use (outputs values per team in provided order)
./gm pre-sprint report mdm,soft --format csv
```
## 🎮 Interactive Controls

View file

@ -43,6 +43,7 @@ func main() {
rootCmd.AddCommand(sprintCmd)
rootCmd.AddCommand(milestoneCmd)
rootCmd.AddCommand(roadmapCmd)
rootCmd.AddCommand(preSprintCmd)
// Test command to test SetCurrentSprint functionality
rootCmd.AddCommand(&cobra.Command{

View file

@ -3,11 +3,14 @@ package main
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/spf13/cobra"
"fleetdm/gm/pkg/ghapi"
"fleetdm/gm/pkg/tui"
"fleetdm/gm/pkg/util"
)
// milestoneCmd is the parent command for milestone-related operations.
@ -58,7 +61,7 @@ var milestoneReportCmd = &cobra.Command{
for _, m := range miles {
t := m.Title
if milestoneStripEmojis {
t = stripEmojis(t)
t = util.StripEmojis(t)
}
fmt.Println(strings.Join([]string{t, m.State}, "\t"))
}
@ -69,7 +72,7 @@ var milestoneReportCmd = &cobra.Command{
for _, m := range miles {
t := m.Title
if milestoneStripEmojis {
t = stripEmojis(t)
t = util.StripEmojis(t)
}
fmt.Printf("| %s | %s |\n", t, m.State)
}
@ -139,7 +142,7 @@ var milestoneReportCmd = &cobra.Command{
filtered = append(filtered, p)
continue
}
name := strings.ToLower(stripEmojis(title))
name := strings.ToLower(util.StripEmojis(title))
exclude := false
for _, tok := range toks {
if tok != "" && strings.Contains(name, tok) {
@ -160,7 +163,7 @@ var milestoneReportCmd = &cobra.Command{
if p.Title != "" {
title := p.Title
if milestoneStripEmojis {
title = stripEmojis(title)
title = util.StripEmojis(title)
}
headers = append(headers, title)
} else {
@ -218,7 +221,7 @@ var milestoneReportCmd = &cobra.Command{
if strings.TrimSpace(ps.Status) == "" {
cell := "No Status"
if milestoneStripEmojis {
cell = stripEmojis(cell)
cell = util.StripEmojis(cell)
}
row = append(row, cell)
if agg[pid] == nil {
@ -228,7 +231,7 @@ var milestoneReportCmd = &cobra.Command{
} else {
cell := ps.Status
if milestoneStripEmojis {
cell = stripEmojis(cell)
cell = util.StripEmojis(cell)
}
row = append(row, cell)
if agg[pid] == nil {
@ -240,9 +243,9 @@ var milestoneReportCmd = &cobra.Command{
// Append truncated title column
title := mi.Title
if milestoneStripEmojis {
title = stripEmojis(title)
title = util.StripEmojis(title)
}
row = append(row, truncateTitle(title, 25))
row = append(row, util.TruncateTitle(title, 25))
if format == "tsv" {
fmt.Println(strings.Join(row, "\t"))
} else {
@ -281,13 +284,13 @@ var milestoneReportCmd = &cobra.Command{
}
sort.Slice(rows, func(i, j int) bool {
if key == "name" {
pi := plainForSort(rows[i].Project)
pj := plainForSort(rows[j].Project)
pi := util.PlainForSort(rows[i].Project)
pj := util.PlainForSort(rows[j].Project)
if pi != pj {
return pi < pj
}
si := plainForSort(rows[i].Status)
sj := plainForSort(rows[j].Status)
si := util.PlainForSort(rows[i].Status)
sj := util.PlainForSort(rows[j].Status)
if si != sj {
return si < sj
}
@ -299,13 +302,13 @@ var milestoneReportCmd = &cobra.Command{
if rows[i].Count != rows[j].Count {
return rows[i].Count < rows[j].Count
}
pi := plainForSort(rows[i].Project)
pj := plainForSort(rows[j].Project)
pi := util.PlainForSort(rows[i].Project)
pj := util.PlainForSort(rows[j].Project)
if pi != pj {
return pi < pj
}
si := plainForSort(rows[i].Status)
sj := plainForSort(rows[j].Status)
si := util.PlainForSort(rows[i].Status)
sj := util.PlainForSort(rows[j].Status)
if si != sj {
return si < sj
}
@ -320,8 +323,8 @@ var milestoneReportCmd = &cobra.Command{
proj := r.Project
stat := r.Status
if milestoneStripEmojis {
proj = stripEmojis(proj)
stat = stripEmojis(stat)
proj = util.StripEmojis(proj)
stat = util.StripEmojis(stat)
}
fmt.Println(strings.Join([]string{proj, stat, fmt.Sprintf("%d", r.Count)}, "\t"))
}
@ -334,8 +337,8 @@ var milestoneReportCmd = &cobra.Command{
proj := r.Project
stat := r.Status
if milestoneStripEmojis {
proj = stripEmojis(proj)
stat = stripEmojis(stat)
proj = util.StripEmojis(proj)
stat = util.StripEmojis(stat)
}
fmt.Printf("| %s | %s | %d |\n", proj, stat, r.Count)
}
@ -345,8 +348,49 @@ var milestoneReportCmd = &cobra.Command{
},
}
// milestoneViewCmd shows the interactive issues UI populated by issues tied to a given milestone
var milestoneViewCmd = &cobra.Command{
Use: "view <milestone-id-or-title>",
Short: "Interactive view of issues in a milestone",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ident := strings.TrimSpace(args[0])
if ident == "" {
return fmt.Errorf("milestone identifier is required")
}
// Resolve to milestone title: accept numeric id or title directly
title := ident
if n, err := strconv.Atoi(ident); err == nil {
// Look up milestone by number across open+closed
miles, lerr := ghapi.ListRepoMilestones(true)
if lerr != nil {
return fmt.Errorf("failed to list milestones: %v", lerr)
}
found := ""
for _, m := range miles {
if m.Number == n {
found = m.Title
break
}
}
if found == "" {
return fmt.Errorf("milestone #%d not found", n)
}
title = found
}
// Run the TUI with milestone mode so we can enforce limit and warn when overflow
lim, _ := cmd.Flags().GetInt("limit")
tui.RunTUI(tui.MilestoneCommand, 0, lim, title)
return nil
},
}
func init() {
milestoneCmd.AddCommand(milestoneReportCmd)
milestoneCmd.AddCommand(milestoneViewCmd)
milestoneViewCmd.Flags().Int("limit", 300, "Maximum number of issues to fetch for the milestone (shows warning if more exist)")
milestoneReportCmd.Flags().StringVar(&milestoneFormat, "format", "tsv", "Output format: tsv (default) or md")
milestoneReportCmd.Flags().BoolVar(&milestoneStripEmojis, "strip-emojis", false, "Strip emojis from project titles and statuses")
milestoneReportCmd.Flags().StringVar(&milestoneSummarySort, "summary-sort", "count", "Summary sort: count (default) or name")
@ -354,44 +398,3 @@ func init() {
milestoneReportCmd.Flags().StringVar(&milestoneIgnoreProject, "ignore-project", "", "Comma-separated substrings to exclude matching project titles (case-insensitive). Example: 'qa,cust' excludes ':help-qa', ':help-customers', and 'Customer requests (open)'.")
milestoneReportCmd.Flags().StringVar(&milestoneFilterLabels, "filter-labels", "", "Comma-separated list of labels; only issues containing ALL of these labels are included (case-insensitive). Example: 'story,customer-numa'.")
}
// stripEmojis removes common emoji and pictographic characters from a string,
// including variation selectors and zero-width joiners, leaving readable text.
func stripEmojis(s string) string {
var b strings.Builder
for _, r := range s {
// Skip variation selector and zero-width joiners/spaces
if r == 0xFE0F || r == 0x200D || r == 0x200C || r == 0x200B {
continue
}
// Common emoji blocks and symbols/pictographs
if (r >= 0x1F300 && r <= 0x1FAFF) || // Misc symbols & pictographs to Supplemental symbols
(r >= 0x2600 && r <= 0x27BF) { // Misc symbols + Dingbats
continue
}
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
// plainForSort returns a simplified string without emojis, lowercased, for consistent sorting
func plainForSort(s string) string {
return strings.ToLower(stripEmojis(s))
}
// truncateTitle truncates a string to maxRunes characters (by rune count) and appends
// "..." if the original was longer. The ellipsis is not counted toward maxRunes.
func truncateTitle(s string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
count := 0
for idx := range s {
if count == maxRunes {
// idx is byte index at rune boundary for the first runes
return s[:idx] + "..."
}
count++
}
return s
}

View file

@ -0,0 +1,154 @@
package main
import (
"fmt"
"strings"
"fleetdm/gm/pkg/ghapi"
"fleetdm/gm/pkg/util"
"github.com/spf13/cobra"
)
// preSprintCmd is the parent command for pre-sprint utilities
var preSprintCmd = &cobra.Command{
Use: "pre-sprint",
Short: "Pre-sprint utilities",
}
var (
preSprintLimit int
preSprintFormat string
)
// preSprintReportCmd implements: gm pre-sprint report <project-id or alias[,alias...]>
var preSprintReportCmd = &cobra.Command{
Use: "report [project-id-or-alias]",
Short: "Generate pre-sprint report for one or more teams",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
identList := strings.Split(args[0], ",")
teamIDs := make([]int, 0, len(identList))
teamLabels := make([]string, 0, len(identList))
teamNames := make([]string, 0, len(identList))
for _, ident := range identList {
ident = strings.TrimSpace(ident)
if ident == "" {
continue
}
pid, err := ghapi.ResolveProjectID(ident)
if err != nil {
fmt.Printf("Skipping '%s': %v\n", ident, err)
continue
}
label, ok := ghapi.ProjectLabels[pid]
if !ok {
fmt.Printf("Skipping '%s' (project %d): no drafting label mapping. Supported projects: mdm, soft, orch, sec.\n", ident, pid)
continue
}
teamIDs = append(teamIDs, pid)
teamLabels = append(teamLabels, label)
teamNames = append(teamNames, ident)
}
if len(teamIDs) == 0 {
return fmt.Errorf("no valid teams provided")
}
// Resolve status option names from the drafting project once
draftingProjectID := ghapi.Aliases["draft"]
readyName, err := ghapi.FindFieldValueByName(draftingProjectID, "Status", "ready to estimate")
if err != nil {
return fmt.Errorf("failed to resolve 'ready to estimate' status in drafting project: %v", err)
}
estimatedName, err := ghapi.FindFieldValueByName(draftingProjectID, "Status", "estimated")
if err != nil {
return fmt.Errorf("failed to resolve 'estimated' status in drafting project: %v", err)
}
stop := util.StartSpinner("Fetching drafting items")
items, total, err := ghapi.GetProjectItemsWithTotal(draftingProjectID, preSprintLimit)
stop()
if err != nil {
return fmt.Errorf("failed to get drafting project items: %v", err)
}
if total > preSprintLimit {
fmt.Printf("Warning: Drafting project has %d items, but only fetched %d. Some items may be omitted.\n", total, preSprintLimit)
}
// If CSV format, print header once
if strings.EqualFold(preSprintFormat, "csv") {
fmt.Println("\nunestimated,total sp,priority sp,customer sp,priority-customer overlap")
}
// For each team, compute metrics from the same drafting items
for idx := range teamIDs {
teamLabel := strings.ToLower(strings.TrimSpace(teamLabels[idx]))
teamName := teamNames[idx]
// Accumulators
unestimatedBugs := 0
estBugPoints := 0
priorityBugPoints := 0
customerBugPoints := 0
overlapPoints := 0
for _, it := range items {
// status filter
if !util.StatusMatches(it.Status, readyName, estimatedName) {
continue
}
// team label filter
if !util.HasLabel(it.Labels, teamLabel) {
continue
}
// bug filter
isBug := util.HasLabel(it.Labels, "bug")
if !isBug {
continue
}
est := it.Estimate
if est == 0 {
unestimatedBugs++
}
estBugPoints += est
isPriority := util.HasAnyLabel(it.Labels, "P0", "P1", "P2")
isCustomer := util.HasLabelPrefix(it.Labels, "customer-")
if isPriority {
priorityBugPoints += est
}
if isCustomer {
customerBugPoints += est
}
if isPriority && isCustomer {
overlapPoints += est
}
}
if strings.EqualFold(preSprintFormat, "csv") {
fmt.Printf("%d,%d,%d,%d,%d\n", unestimatedBugs, estBugPoints, priorityBugPoints, customerBugPoints, overlapPoints)
} else {
// Default 'out' format
fmt.Printf("\nPre-sprint report for %s\n", teamName)
fmt.Printf("Total number of unestimated bugs: %d\n", unestimatedBugs)
fmt.Printf("Total story points of estimated bugs: %d\n", estBugPoints)
fmt.Printf("Total story points of priority bugs: %d\n", priorityBugPoints)
fmt.Printf("Total story points of customer reported bugs: %d\n", customerBugPoints)
fmt.Printf("Total overlap of priority and customer bugs: %d\n", overlapPoints)
}
}
return nil
},
}
func init() {
preSprintCmd.AddCommand(preSprintReportCmd)
preSprintReportCmd.Flags().IntVarP(&preSprintLimit, "limit", "l", 1000, "Maximum number of items to fetch from drafting project")
preSprintReportCmd.Flags().StringVar(&preSprintFormat, "format", "out", "Output format: out (default) or csv")
}
// Helpers moved to pkg/util

View file

@ -31,9 +31,9 @@ var projectCmd = &cobra.Command{
}
func init() {
projectCmd.Flags().IntP("limit", "l", 100, "Maximum number of items to fetch")
projectCmd.Flags().IntP("limit", "l", 300, "Maximum number of items to fetch")
estimatedCmd.Flags().IntP("limit", "l", 500, "Maximum number of items to fetch from drafting project")
sprintCmd.Flags().IntP("limit", "l", 100, "Maximum number of items to fetch")
sprintCmd.Flags().IntP("limit", "l", 300, "Maximum number of items to fetch")
sprintCmd.Flags().BoolP("previous", "p", false, "Show previous sprint instead of current")
}

View file

@ -227,12 +227,14 @@ func getIssueEstimatesAcrossProjects(issueNumber int) (map[int]int, error) {
Issue struct {
ProjectItems struct {
Nodes []struct {
Project struct{ Number int `json:"number"` } `json:"project"`
FieldValues struct{
Nodes []struct{
Typename string `json:"__typename"`
Project struct {
Number int `json:"number"`
} `json:"project"`
FieldValues struct {
Nodes []struct {
Typename string `json:"__typename"`
Number *float64 `json:"number,omitempty"`
Field struct{
Field struct {
Name string `json:"name"`
} `json:"field"`
} `json:"nodes"`

View file

@ -37,6 +37,32 @@ func GetIssues(search string) ([]Issue, error) {
return issues, nil
}
// GetIssuesByMilestoneLimited returns up to 'limit' issues for the given milestone title.
// It fetches limit+1 to detect whether there are more results than the limit; the returned
// slice is trimmed to 'limit', and the boolean indicates if more were available.
func GetIssuesByMilestoneLimited(title string, limit int) ([]Issue, bool, error) {
if limit <= 0 {
limit = 300
}
// Request one extra to detect overflow
reqLimit := limit + 1
cmd := fmt.Sprintf("gh issue list --milestone %q --json number,title,author,createdAt,updatedAt,state,labels,body --limit %d", title, reqLimit)
out, err := RunCommandAndReturnOutput(cmd)
if err != nil {
return nil, false, err
}
issues, err := ParseJSONtoIssues(out)
if err != nil {
return nil, false, err
}
exceeded := false
if len(issues) > limit {
exceeded = true
issues = issues[:limit]
}
return issues, exceeded, nil
}
// AddLabelToIssue adds a label to an issue.
func AddLabelToIssue(issueNumber int, label string) error {
command := fmt.Sprintf("gh issue edit %d --add-label %s", issueNumber, label)

View file

@ -10,6 +10,7 @@ import (
"time"
"fleetdm/gm/pkg/logger"
"fleetdm/gm/pkg/util"
)
// GetIssuesByMilestone returns issue numbers for a given milestone name. Limit controls max items.
@ -299,6 +300,36 @@ func looksLikeRateLimit(s string) bool {
return false
}
// GetMilestoneOpenIssueCount returns the count of OPEN issues for the given milestone title in the current repo.
// This uses GitHub GraphQL search to retrieve the issueCount efficiently without listing all issues.
func GetMilestoneOpenIssueCount(title string) (int, error) {
owner, repo, err := getRepoOwnerAndName()
if err != nil {
return 0, err
}
// Build a search query limited to the current repository, milestone title, and open issues.
q := fmt.Sprintf("repo:%s/%s is:issue is:open milestone:\"%s\"", owner, repo, title)
gql := `query($q:String!){ search(type: ISSUE, query: $q, first: 1){ issueCount } }`
cmd := fmt.Sprintf("gh api graphql -f query='%s' -f q='%s'", gql, util.EscapeSingleQuotes(q))
out, err := runCommandWithRetry(cmd, 5, 2*time.Second)
if err != nil {
return 0, err
}
var resp struct {
Data struct {
Search struct {
IssueCount int `json:"issueCount"`
} `json:"search"`
} `json:"data"`
}
if err := json.Unmarshal(out, &resp); err != nil {
return 0, err
}
return resp.Data.Search.IssueCount, nil
}
// escape moved to util.EscapeSingleQuotes
// RepoMilestone represents a repository milestone (from REST API).
type RepoMilestone struct {
Number int `json:"number"`

View file

@ -0,0 +1,27 @@
package messages
import "fmt"
// LoadingMessage returns a human-friendly loading message based on a simple key.
// Keys: "issues", "project", "estimated", "sprint", "milestone".
func LoadingMessage(key string, projectID int, hint string) string {
switch key {
case "issues":
return "Fetching Issues..."
case "project":
return fmt.Sprintf("Fetching Project Items (ID: %d)...", projectID)
case "estimated":
return fmt.Sprintf("Fetching Estimated Tickets (Project: %d)...", projectID)
case "sprint":
return fmt.Sprintf("Fetching Sprint Items (Project: %d)...", projectID)
case "milestone":
return fmt.Sprintf("Fetching Milestone Issues (%s)...", hint)
default:
return "Fetching..."
}
}
// LimitExceeded returns the common banner message about items not shown due to limit.
func LimitExceeded(missing, limit, total int) string {
return fmt.Sprintf("⚠ %d items not shown (limit=%d, total=%d). Increase --limit to include all issues.", missing, limit, total)
}

View file

@ -214,7 +214,7 @@ func (m *model) HandleHotkeys(msg tea.Msg) (tea.Model, tea.Cmd) {
m.cursor = 0
m.adjustViewForCursor()
}
case "q", "ctrl+c":
case "ctrl+c":
return m, tea.Quit
default:
// Add character to filter
@ -227,6 +227,8 @@ func (m *model) HandleHotkeys(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case WorkflowSelection:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "j", "down":
if m.workflowCursor < len(WorkflowTypeValues)-1 {
m.workflowCursor++
@ -261,6 +263,8 @@ func (m *model) HandleHotkeys(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case LabelInput:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "enter":
if m.labelInput != "" {
return m, m.executeWorkflow()
@ -280,6 +284,8 @@ func (m *model) HandleHotkeys(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case ProjectInput:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "enter":
if m.projectInput != "" {
return m, m.executeWorkflow()

View file

@ -6,6 +6,7 @@ import (
"strings"
"fleetdm/gm/pkg/ghapi"
"fleetdm/gm/pkg/logger"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
@ -22,6 +23,7 @@ const (
ProjectCommand
EstimatedCommand
SprintCommand
MilestoneCommand
)
type WorkflowState int
@ -202,6 +204,8 @@ func RunTUI(commandType CommandType, projectID int, limit int, search string) {
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")
@ -304,6 +308,14 @@ func initializeModelForSprint(projectID, limit int) model {
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 {
@ -360,6 +372,27 @@ func fetchPreviousSprintItems(projectID, limit int) tea.Cmd {
}
}
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
@ -377,6 +410,8 @@ func (m model) Init() tea.Cmd {
} else {
fetchCmd = fetchSprintItems(m.projectID, m.limit)
}
case MilestoneCommand:
fetchCmd = fetchMilestoneItems(m.search, m.limit)
default:
fetchCmd = fetchIssues("")
}
@ -504,8 +539,12 @@ func (m *model) executeWorkflow() tea.Cmd {
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
if err == nil {
if user.Name != "" {
return user.Name
} else {
return user.Login
}
}
}
return "unassigned"

View file

@ -5,6 +5,7 @@ import (
"strings"
"fleetdm/gm/pkg/ghapi"
"fleetdm/gm/pkg/messages"
)
func (m *model) generateIssueContent(issue ghapi.Issue) string {
@ -75,18 +76,21 @@ func (m *model) generateIssueContent(issue ghapi.Issue) string {
}
func (m model) RenderLoading() string {
var loadingMessage string
// Map command type to key for messages catalog
key := "issues"
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)
key = "project"
case EstimatedCommand:
loadingMessage = fmt.Sprintf("Fetching Estimated Tickets (Project: %d)...", m.projectID)
default:
loadingMessage = "Fetching Issues..."
key = "estimated"
case SprintCommand:
key = "sprint"
case MilestoneCommand:
key = "milestone"
case IssuesCommand:
key = "issues"
}
loadingMessage := messages.LoadingMessage(key, m.projectID, m.search)
return fmt.Sprintf("\n%s %s\n\n", m.spinner.View(), loadingMessage)
}
@ -252,7 +256,7 @@ func (m model) RenderIssueTable() string {
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"
warningBanner = errorStyle.Render(messages.LimitExceeded(missing, m.rawFetchedCount, m.totalAvailable)) + "\n\n"
}
s = warningBanner + headerText + fmt.Sprintf(" %-2d/%-2d Number Estimate Type Labels Title\n",
@ -394,7 +398,7 @@ func (m model) selectedWorkflowDescription() string {
case BulkSprintKickoff:
desc = "Add selected issues to a project and set initial sprint kickoff fields (status, estimate sync, labels)."
case BulkMilestoneClose:
desc = "Generate a release summary from selected issues (features/bugs) suitable for milestone close notes."
desc = "Add to drafting and set to confirm and celebrate, removes :release label and adds :product label for selected issues (features/bugs). Does not filter by label, should only be used on stories, does not affect the milestone in any way."
case BulkKickOutOfSprint:
desc = "Remove selected issues from a project and reset sprint-related fields (status, labels)."
case BulkDemoSummary:

View file

@ -0,0 +1,36 @@
package util
import "strings"
// HasLabel returns true if labels contains a label equal to want (case-insensitive, trimmed).
func HasLabel(labels []string, want string) bool {
lw := strings.ToLower(strings.TrimSpace(want))
for _, l := range labels {
if strings.ToLower(strings.TrimSpace(l)) == lw {
return true
}
}
return false
}
// HasAnyLabel returns true if labels contains any of the wants.
func HasAnyLabel(labels []string, wants ...string) bool {
for _, w := range wants {
if HasLabel(labels, w) {
return true
}
}
return false
}
// HasLabelPrefix returns true if any label starts with the given prefix (case-insensitive).
func HasLabelPrefix(labels []string, prefix string) bool {
lp := strings.ToLower(strings.TrimSpace(prefix))
for _, l := range labels {
ll := strings.ToLower(strings.TrimSpace(l))
if strings.HasPrefix(ll, lp) {
return true
}
}
return false
}

View file

@ -0,0 +1,27 @@
package util
import (
"fmt"
"time"
)
// StartSpinner prints an inline spinner until the returned stop function is called.
func StartSpinner(msg string) func() {
done := make(chan struct{})
go func() {
chars := []rune{'|', '/', '-', '\\'}
i := 0
for {
select {
case <-done:
fmt.Printf("\r%s... done.\n", msg)
return
default:
fmt.Printf("\r%s %c", msg, chars[i%len(chars)])
i++
}
time.Sleep(100 * time.Millisecond)
}
}()
return func() { close(done) }
}

View file

@ -0,0 +1,14 @@
package util
import "strings"
// StatusMatches compares status to any of the provided names (case-insensitive, trimmed).
func StatusMatches(status string, names ...string) bool {
ls := strings.ToLower(strings.TrimSpace(status))
for _, n := range names {
if ls == strings.ToLower(strings.TrimSpace(n)) {
return true
}
}
return false
}

View file

@ -0,0 +1,50 @@
package util
import (
"strings"
"unicode/utf8"
)
// StripEmojis removes emoji and pictographic characters and certain joiners/variation selectors
// to leave readable text.
func StripEmojis(s string) string {
var b strings.Builder
for _, r := range s {
if r == 0xFE0F || r == 0x200D || r == 0x200C || r == 0x200B {
continue
}
if (r >= 0x1F300 && r <= 0x1FAFF) || (r >= 0x2600 && r <= 0x27BF) {
continue
}
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
// PlainForSort returns a simplified string without emojis, lowercased, for consistent sorting
func PlainForSort(s string) string {
return strings.ToLower(StripEmojis(s))
}
// TruncateTitle truncates a string to maxRunes runes and appends "..." if longer.
func TruncateTitle(s string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
count := 0
for idx := range s {
if count == maxRunes {
return s[:idx] + "..."
}
count++
}
return s
}
// EscapeSingleQuotes escapes single quotes for safe inclusion inside single-quoted shell strings.
func EscapeSingleQuotes(s string) string {
return strings.ReplaceAll(s, "'", "'\\''")
}
// SafeRuneLen returns runes count of a string.
func SafeRuneLen(s string) int { return utf8.RuneCountInString(s) }