mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
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).
217 lines
6.3 KiB
Go
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)")
|
|
}
|