mirror of
https://github.com/fleetdm/fleet
synced 2026-05-05 06:18:25 +00:00
400 lines
12 KiB
Go
400 lines
12 KiB
Go
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.
|
|
var milestoneCmd = &cobra.Command{
|
|
Use: "milestone",
|
|
Short: "Milestone-related utilities",
|
|
}
|
|
|
|
var (
|
|
milestoneFormat string
|
|
milestoneStripEmojis bool
|
|
milestoneSummarySort string
|
|
milestoneIncludeClosed bool
|
|
milestoneIgnoreProject string
|
|
milestoneFilterLabels string
|
|
)
|
|
|
|
var milestoneReportCmd = &cobra.Command{
|
|
Use: "report <milestone-name>",
|
|
Short: "Print a table of issues and their project statuses for a milestone",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
milestoneName := args[0]
|
|
|
|
// Fetch issues in milestone (with titles)
|
|
msIssues, err := ghapi.GetIssuesByMilestoneWithTitles(milestoneName, 1000)
|
|
if err != nil || len(msIssues) == 0 {
|
|
// If milestone doesn't exist or has no issues, list available milestones
|
|
miles, lerr := ghapi.ListRepoMilestones(milestoneIncludeClosed)
|
|
if lerr != nil {
|
|
return fmt.Errorf("failed to find milestone '%s' and also failed to list milestones: %v", milestoneName, lerr)
|
|
}
|
|
format := strings.ToLower(strings.TrimSpace(milestoneFormat))
|
|
if format == "" {
|
|
format = "tsv"
|
|
}
|
|
// Helpful hint when filtering open-only yields none
|
|
var msg string
|
|
if len(miles) == 0 && !milestoneIncludeClosed {
|
|
msg = fmt.Sprintf("No open milestones found. Use --include-closed to include closed milestones. (Requested milestone: '%s')", milestoneName)
|
|
} else {
|
|
msg = fmt.Sprintf("No issues found for milestone '%s'. Available milestones:", milestoneName)
|
|
}
|
|
switch format {
|
|
case "tsv":
|
|
fmt.Println(msg)
|
|
fmt.Println(strings.Join([]string{"Title", "State"}, "\t"))
|
|
for _, m := range miles {
|
|
t := m.Title
|
|
if milestoneStripEmojis {
|
|
t = util.StripEmojis(t)
|
|
}
|
|
fmt.Println(strings.Join([]string{t, m.State}, "\t"))
|
|
}
|
|
case "md", "markdown":
|
|
fmt.Println(msg)
|
|
fmt.Printf("| %s |\n", strings.Join([]string{"Title", "State"}, " | "))
|
|
fmt.Printf("| %s |\n", strings.Join([]string{"---", "---"}, " | "))
|
|
for _, m := range miles {
|
|
t := m.Title
|
|
if milestoneStripEmojis {
|
|
t = util.StripEmojis(t)
|
|
}
|
|
fmt.Printf("| %s | %s |\n", t, m.State)
|
|
}
|
|
default:
|
|
return fmt.Errorf("unsupported --format %q (use: tsv or md)", milestoneFormat)
|
|
}
|
|
// Return error so callers can detect non-report condition
|
|
return fmt.Errorf("milestone '%s' not found or empty", milestoneName)
|
|
}
|
|
// Optional label filtering: require all specified labels to be present
|
|
if strings.TrimSpace(milestoneFilterLabels) != "" {
|
|
wantParts := strings.Split(milestoneFilterLabels, ",")
|
|
wants := make([]string, 0, len(wantParts))
|
|
for _, wp := range wantParts {
|
|
w := strings.ToLower(strings.TrimSpace(wp))
|
|
if w != "" {
|
|
wants = append(wants, w)
|
|
}
|
|
}
|
|
if len(wants) > 0 {
|
|
filtered := make([]ghapi.MilestoneIssue, 0, len(msIssues))
|
|
issueLoop:
|
|
for _, mi := range msIssues {
|
|
labelSet := make(map[string]struct{}, len(mi.Labels))
|
|
for _, l := range mi.Labels {
|
|
ln := strings.ToLower(strings.TrimSpace(l.Name))
|
|
if ln != "" {
|
|
labelSet[ln] = struct{}{}
|
|
}
|
|
}
|
|
for _, want := range wants {
|
|
if _, ok := labelSet[want]; !ok {
|
|
continue issueLoop
|
|
}
|
|
}
|
|
filtered = append(filtered, mi)
|
|
}
|
|
msIssues = filtered
|
|
}
|
|
}
|
|
|
|
// Extract numbers for project discovery (post filtering)
|
|
issueNums := make([]int, 0, len(msIssues))
|
|
for _, it := range msIssues {
|
|
issueNums = append(issueNums, it.Number)
|
|
}
|
|
|
|
// Determine all projects across these issues, dynamically
|
|
projects, _ := ghapi.GetProjectsForIssues(issueNums)
|
|
// Apply ignore-project filtering if provided
|
|
if strings.TrimSpace(milestoneIgnoreProject) != "" {
|
|
// build tokens (case-insensitive substring match)
|
|
parts := strings.Split(milestoneIgnoreProject, ",")
|
|
toks := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
t := strings.ToLower(strings.TrimSpace(p))
|
|
if t != "" {
|
|
toks = append(toks, t)
|
|
}
|
|
}
|
|
if len(toks) > 0 {
|
|
filtered := make([]ghapi.ProjectInfo, 0, len(projects))
|
|
for _, p := range projects {
|
|
title := p.Title
|
|
if title == "" {
|
|
// Without a title we can't match by name; keep it
|
|
filtered = append(filtered, p)
|
|
continue
|
|
}
|
|
name := strings.ToLower(util.StripEmojis(title))
|
|
exclude := false
|
|
for _, tok := range toks {
|
|
if tok != "" && strings.Contains(name, tok) {
|
|
exclude = true
|
|
break
|
|
}
|
|
}
|
|
if !exclude {
|
|
filtered = append(filtered, p)
|
|
}
|
|
}
|
|
projects = filtered
|
|
}
|
|
}
|
|
headers := make([]string, 0, len(projects)+2)
|
|
headers = append(headers, "Number")
|
|
for _, p := range projects {
|
|
if p.Title != "" {
|
|
title := p.Title
|
|
if milestoneStripEmojis {
|
|
title = util.StripEmojis(title)
|
|
}
|
|
headers = append(headers, title)
|
|
} else {
|
|
headers = append(headers, fmt.Sprintf("%d", p.ID))
|
|
}
|
|
}
|
|
// Final column header for issue title
|
|
headers = append(headers, "Title")
|
|
|
|
format := strings.ToLower(strings.TrimSpace(milestoneFormat))
|
|
if format == "" {
|
|
format = "tsv"
|
|
}
|
|
|
|
switch format {
|
|
case "tsv":
|
|
// Print header as TSV
|
|
// Heading line first
|
|
fmt.Printf("Milestone\treport\t%s\n", milestoneName)
|
|
fmt.Println(strings.Join(headers, "\t"))
|
|
case "md", "markdown":
|
|
// Heading line first
|
|
fmt.Printf("|Milestone report %s|\n", milestoneName)
|
|
// Markdown header
|
|
fmt.Printf("| %s |\n", strings.Join(headers, " | "))
|
|
// Separator row
|
|
seps := make([]string, len(headers))
|
|
for i := range seps {
|
|
seps[i] = "---"
|
|
}
|
|
fmt.Printf("| %s |\n", strings.Join(seps, " | "))
|
|
default:
|
|
return fmt.Errorf("unsupported --format %q (use: tsv or md)", milestoneFormat)
|
|
}
|
|
|
|
// Aggregator: projectID -> status -> count (only when Present)
|
|
agg := make(map[int]map[string]int, len(projects))
|
|
|
|
// For each issue, gather statuses per project
|
|
for _, mi := range msIssues {
|
|
num := mi.Number
|
|
// Build the list of project IDs in header order
|
|
pids := make([]int, 0, len(projects))
|
|
for _, p := range projects {
|
|
pids = append(pids, p.ID)
|
|
}
|
|
statuses, _ := ghapi.GetIssueProjectStatuses(num, pids)
|
|
row := []string{fmt.Sprintf("%d", num)}
|
|
for _, pid := range pids {
|
|
ps, ok := statuses[pid]
|
|
if !ok || !ps.Present {
|
|
row = append(row, "-")
|
|
continue
|
|
}
|
|
if strings.TrimSpace(ps.Status) == "" {
|
|
cell := "No Status"
|
|
if milestoneStripEmojis {
|
|
cell = util.StripEmojis(cell)
|
|
}
|
|
row = append(row, cell)
|
|
if agg[pid] == nil {
|
|
agg[pid] = make(map[string]int)
|
|
}
|
|
agg[pid]["No Status"]++
|
|
} else {
|
|
cell := ps.Status
|
|
if milestoneStripEmojis {
|
|
cell = util.StripEmojis(cell)
|
|
}
|
|
row = append(row, cell)
|
|
if agg[pid] == nil {
|
|
agg[pid] = make(map[string]int)
|
|
}
|
|
agg[pid][ps.Status]++
|
|
}
|
|
}
|
|
// Append truncated title column
|
|
title := mi.Title
|
|
if milestoneStripEmojis {
|
|
title = util.StripEmojis(title)
|
|
}
|
|
row = append(row, util.TruncateTitle(title, 25))
|
|
if format == "tsv" {
|
|
fmt.Println(strings.Join(row, "\t"))
|
|
} else {
|
|
fmt.Printf("| %s |\n", strings.Join(row, " | "))
|
|
}
|
|
}
|
|
|
|
// Build and print summary rows: Project, Status, Count
|
|
type sumRow struct {
|
|
Project string
|
|
ProjectID int
|
|
Status string
|
|
Count int
|
|
}
|
|
rows := make([]sumRow, 0)
|
|
// helper: title by pid
|
|
getProjTitle := func(pid int) string {
|
|
for _, p := range projects {
|
|
if p.ID == pid {
|
|
return p.Title
|
|
}
|
|
}
|
|
return fmt.Sprintf("%d", pid)
|
|
}
|
|
for pid, m := range agg {
|
|
title := getProjTitle(pid)
|
|
for status, c := range m {
|
|
rows = append(rows, sumRow{Project: title, ProjectID: pid, Status: status, Count: c})
|
|
}
|
|
}
|
|
if len(rows) > 0 {
|
|
// sorting: default by count asc; if --summary-sort name, sort by project name (emoji-stripped), then status
|
|
key := strings.ToLower(strings.TrimSpace(milestoneSummarySort))
|
|
if key == "" {
|
|
key = "count"
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if key == "name" {
|
|
pi := util.PlainForSort(rows[i].Project)
|
|
pj := util.PlainForSort(rows[j].Project)
|
|
if pi != pj {
|
|
return pi < pj
|
|
}
|
|
si := util.PlainForSort(rows[i].Status)
|
|
sj := util.PlainForSort(rows[j].Status)
|
|
if si != sj {
|
|
return si < sj
|
|
}
|
|
if rows[i].Count != rows[j].Count {
|
|
return rows[i].Count < rows[j].Count
|
|
}
|
|
return rows[i].ProjectID < rows[j].ProjectID
|
|
}
|
|
if rows[i].Count != rows[j].Count {
|
|
return rows[i].Count < rows[j].Count
|
|
}
|
|
pi := util.PlainForSort(rows[i].Project)
|
|
pj := util.PlainForSort(rows[j].Project)
|
|
if pi != pj {
|
|
return pi < pj
|
|
}
|
|
si := util.PlainForSort(rows[i].Status)
|
|
sj := util.PlainForSort(rows[j].Status)
|
|
if si != sj {
|
|
return si < sj
|
|
}
|
|
return rows[i].ProjectID < rows[j].ProjectID
|
|
})
|
|
|
|
if format == "tsv" {
|
|
fmt.Println()
|
|
fmt.Println("Summary")
|
|
fmt.Println(strings.Join([]string{"Project", "Status", "Count"}, "\t"))
|
|
for _, r := range rows {
|
|
proj := r.Project
|
|
stat := r.Status
|
|
if milestoneStripEmojis {
|
|
proj = util.StripEmojis(proj)
|
|
stat = util.StripEmojis(stat)
|
|
}
|
|
fmt.Println(strings.Join([]string{proj, stat, fmt.Sprintf("%d", r.Count)}, "\t"))
|
|
}
|
|
} else {
|
|
fmt.Println()
|
|
fmt.Println("Summary")
|
|
fmt.Printf("| %s |\n", strings.Join([]string{"Project", "Status", "Count"}, " | "))
|
|
fmt.Printf("| %s |\n", strings.Join([]string{"---", "---", "---"}, " | "))
|
|
for _, r := range rows {
|
|
proj := r.Project
|
|
stat := r.Status
|
|
if milestoneStripEmojis {
|
|
proj = util.StripEmojis(proj)
|
|
stat = util.StripEmojis(stat)
|
|
}
|
|
fmt.Printf("| %s | %s | %d |\n", proj, stat, r.Count)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// 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")
|
|
milestoneReportCmd.Flags().BoolVar(&milestoneIncludeClosed, "include-closed", false, "Include closed milestones when listing available milestones")
|
|
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'.")
|
|
}
|