fleet/tools/github-manage/cmd/gm/bugs.go
Ian Littman 9ce3182726
Add bug open time metric calculation to gm, including milestone exclusion (#39118)
This matches our current bug open time KPI calculation, with the added
ability to exclude milestones from the calculation. Milestone exclusion
allows for easily answering the question "what would happen to the
metric if we released this milestone today?"

Verbose output lists each bug, net of filtering by milestone, including
the product group label associated with the bug (so we can spot cases
where a bug won't get resolved all else equal because it isn't triaged
to a current product group).
2026-02-03 17:59:45 -06:00

217 lines
6.3 KiB
Go

package main
import (
"encoding/json"
"fmt"
"math"
"slices"
"strings"
"time"
"fleetdm/gm/pkg/ghapi"
"github.com/spf13/cobra"
)
var bugsCmd = &cobra.Command{
Use: "bugs",
Short: "Bug-related utilities and reports",
}
// BugIssue represents a GitHub issue with bug label
type BugIssue struct {
Number int `json:"number"`
Title string `json:"title"`
CreatedAt time.Time `json:"createdAt"`
Milestone *struct {
Title string `json:"title"`
} `json:"milestone"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
}
var productGroupLabels = []string{"#g-software", "#g-orchestration", "#g-mdm", "#g-security-compliance"}
func (i BugIssue) ProductGroup() string {
for _, label := range i.Labels {
if slices.Contains(productGroupLabels, label.Name) {
return label.Name
}
}
return "orphan"
}
var bugsAverageOpenCmd = &cobra.Command{
Use: "average-open-days",
Short: "Calculate the average number of days bugs are open",
Long: `Calculate the average number of days bugs are open for the fleetdm/fleet repository.
This command fetches all open issues with the "bug" label and calculates
the average number of days they have been open.
This metric is equivalent to 'averageNumberOfDaysBugsAreOpenFor' from the
website/scripts/get-bug-and-pr-report.js script, used by the bug open time KPI.
Usage:
gm bugs average-open-days
gm bugs average-open-days --verbose
gm bugs average-open-days --without-milestone 4.80.1 --without-milestone 4.81.0
gm bugs average-open-days --format json`,
RunE: func(cmd *cobra.Command, args []string) error {
verbose, _ := cmd.Flags().GetBool("verbose")
format, _ := cmd.Flags().GetString("format")
limit, _ := cmd.Flags().GetInt("limit")
withoutMilestones, _ := cmd.Flags().GetStringSlice("without-milestone")
// Fetch all open bugs using gh CLI
if verbose {
fmt.Println("Fetching open bugs from fleetdm/fleet...")
}
bugs, err := fetchOpenBugs(limit)
if err != nil {
return fmt.Errorf("failed to fetch open bugs: %v", err)
}
if len(bugs) == 0 {
fmt.Println("No open bugs found.")
return nil
}
// Calculate days since each bug was opened
now := time.Now()
var totalDays float64
var bugDetails []struct {
Number int
Title string
DaysOpen float64
ProductGroup string
}
forEachBug:
for _, bug := range bugs {
if bug.Milestone != nil {
for _, milestone := range withoutMilestones {
if milestone == bug.Milestone.Title {
continue forEachBug
}
}
}
daysOpen := now.Sub(bug.CreatedAt).Hours() / 24
totalDays += daysOpen
bugDetails = append(bugDetails, struct {
Number int
Title string
DaysOpen float64
ProductGroup string
}{
Number: bug.Number,
Title: bug.Title,
DaysOpen: daysOpen,
ProductGroup: bug.ProductGroup(),
})
}
averageDays := totalDays / float64(len(bugDetails))
roundedAverage := int(math.Round(averageDays))
// Output based on format
format = strings.ToLower(strings.TrimSpace(format))
switch format {
case "json":
output := struct {
TotalBugs int `json:"totalBugs"`
AverageNumberOfDaysBugsAreOpenFor int `json:"averageNumberOfDaysBugsAreOpenFor"`
AverageDaysExact float64 `json:"averageDaysExact"`
}{
TotalBugs: len(bugs),
AverageNumberOfDaysBugsAreOpenFor: roundedAverage,
AverageDaysExact: averageDays,
}
jsonBytes, err := json.MarshalIndent(output, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %v", err)
}
fmt.Println(string(jsonBytes))
case "tsv":
fmt.Println("Metric\tValue")
fmt.Printf("Total Open Bugs\t%d\n", len(bugs))
if len(withoutMilestones) > 0 {
fmt.Printf("Filtered Open Bugs\t%d\n", len(bugDetails))
}
fmt.Printf("Average Days Open (rounded)\t%d\n", roundedAverage)
fmt.Printf("Average Days Open (exact)\t%.2f\n", averageDays)
default: // human-readable
fmt.Println(strings.Repeat("=", 50))
fmt.Println("Bug Report: Average Open Time")
fmt.Println(strings.Repeat("=", 50))
fmt.Printf("Total open bugs: %d\n", len(bugs))
if len(withoutMilestones) > 0 {
fmt.Printf("Filtered open bugs: %d\n", len(bugDetails))
}
fmt.Printf("Average number of days bugs are open for: %d\n", roundedAverage)
fmt.Printf("Average days (exact): %.2f\n", averageDays)
fmt.Println(strings.Repeat("=", 50))
}
// Verbose output: list all bugs with their open days and labels
if verbose && format != "json" {
fmt.Println("\nDetailed bug list:")
fmt.Println(strings.Repeat("-", 80))
fmt.Printf("%-5s %-4s %-22s %s\n", "ID", "Days", "Product Group", "Title")
fmt.Println(strings.Repeat("-", 80))
for _, bug := range bugDetails {
title := bug.Title
if len(title) > 50 {
title = title[:47] + "..."
}
fmt.Printf("%-5d %-3.1f %-22s %s\n", bug.Number, bug.DaysOpen, bug.ProductGroup, title)
}
}
return nil
},
}
// fetchOpenBugs fetches all open issues with the "bug" label from fleetdm/fleet
func fetchOpenBugs(limit int) ([]BugIssue, error) {
if limit <= 0 {
limit = 1000 // Default high limit to get all bugs
}
// Use gh CLI to fetch open issues with bug label
// The gh CLI handles pagination internally with --limit
command := fmt.Sprintf(
"gh issue list --repo fleetdm/fleet --state open --label bug --json number,title,createdAt,milestone,labels --limit %d",
limit,
)
output, err := ghapi.RunCommandAndReturnOutput(command)
if err != nil {
return nil, fmt.Errorf("gh command failed: %v", err)
}
var bugs []BugIssue
if err := json.Unmarshal(output, &bugs); err != nil {
return nil, fmt.Errorf("failed to parse JSON response: %v", err)
}
if len(bugs) == limit {
return nil, fmt.Errorf("there are at least %d open bugs; choose a larger limit", limit)
}
return bugs, nil
}
func init() {
bugsCmd.AddCommand(bugsAverageOpenCmd)
bugsAverageOpenCmd.Flags().BoolP("verbose", "v", false, "Show detailed list of all bugs with their open days")
bugsAverageOpenCmd.Flags().StringP("format", "f", "", "Output format: json, tsv, or default (human-readable)")
bugsAverageOpenCmd.Flags().IntP("limit", "l", 1000, "Maximum number of bugs to fetch")
bugsAverageOpenCmd.Flags().StringSlice("without-milestone", []string{}, "Bug milestones to exclude (to simulate a release going GA)")
}